From b41ed9f410aab199d99024d32a9e0b4f129711bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Mon, 7 Sep 2026 15:45:01 +0800 Subject: [PATCH 1/6] update version --- src/leapflow/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/leapflow/version.py b/src/leapflow/version.py index 8706c771..bb6160a3 100644 --- a/src/leapflow/version.py +++ b/src/leapflow/version.py @@ -1,3 +1,3 @@ """Version information for leapflow.""" -__version__ = "0.2.1+main" +__version__ = "0.2.0+main" From 9bfb489865d4a927d35ad770c4d02477b6882d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Mon, 7 Sep 2026 22:23:11 +0800 Subject: [PATCH 2/6] leapspace co-evolving --- .github/workflows/ci.yaml | 4 +- Makefile | 8 +- README.md | 19 +- pyproject.toml | 18 +- scripts/setup.sh | 4 +- .../learning/capability_observation.py | 63 +- src/leapflow/plugins/adaptive_loop.py | 41 + src/leapflow/plugins/capability_plan.py | 13 + src/leapflow/plugins/capability_resolver.py | 49 + src/leapflow/plugins/protocol.py | 13 + src/leapspace/__init__.py | 4 +- src/leapspace/app_space/__init__.py | 2 +- tests/leapspace/test_actor.py | 2 +- tests/leapspace/test_base.py | 2 +- tests/leapspace/test_harness.py | 3 + tests/leapspace/test_signal.py | 4 + tests/leapspace/test_utils.py | 2 + uv.lock | 1088 ++++++++--------- 18 files changed, 768 insertions(+), 571 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7ce725ef..bc3f6c3d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -54,7 +54,7 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync --all-extras + run: uv sync --all-extras --no-extra leapspace - name: Lint run: uv run ruff check src/leapflow/ tests/ tools/ @@ -94,7 +94,7 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync --all-extras + run: uv sync --all-extras --no-extra leapspace - name: Lint run: uv run ruff check src/leapflow/ tests/ tools/ diff --git a/Makefile b/Makefile index e7400fd3..42eee2c2 100644 --- a/Makefile +++ b/Makefile @@ -17,11 +17,11 @@ setup: ## Setup scripts permissions and environment chmod +x scripts/setup.sh scripts/run.sh ./scripts/setup.sh -sync: ## Sync all dependencies - uv sync --all-extras +sync: ## Sync dependencies (excludes the heavy leapspace extra) + uv sync --all-extras --no-extra leapspace -space-sync: ## Sync dependencies including LeapSpace - uv sync --all-extras --group leapspace +space-sync: ## Sync all dependencies including the leapspace extra + uv sync --all-extras lint: ## Lint source code uv run ruff check src/ tests/ tools/ diff --git a/README.md b/README.md index b8fe06cb..2d73adc8 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ This installs the `leap` command. The first `leap` run creates the local LeapFlo ```bash git clone https://github.com/modelscope/leapflow.git cd leapflow -uv sync --all-extras +uv sync --all-extras --no-extra leapspace uv run leap --help ``` @@ -342,6 +342,23 @@ leap --mock-host "hello, are you ready?" Expected: LeapFlow responds with a greeting confirming it's operational. +### 5. (Optional) LeapSpace evaluation environment + +LeapSpace (`src/leapspace`) is an opt-in, environment-side CUA sandbox: PyQt6 +scenario apps plus a harness that boots a disposable sandbox, drives a task, and +records signal-mode trajectories as ground truth. Its code ships inside the +leapflow distribution, but the heavy stack (PyQt6 / cua-sandbox / pydantic) is +gated behind an extra so the default install — and CI — stays light: + +```bash +pip install 'leapflow[leapspace]' # from PyPI +uv sync --extra leapspace # or from a source checkout (make space-sync) +``` + +Without the extra, `import leapspace.app_space` still works; only the submodules +that pull in PyQt6 / cua-sandbox / pydantic require it, and the LeapSpace tests +skip cleanly when those dependencies are absent. + --- ## Configuration Reference diff --git a/pyproject.toml b/pyproject.toml index 1dc3b1e2..7e83b893 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,12 +54,13 @@ dashboard = ["aiohttp>=3.9"] # Better main-content extraction for web_fetch. Optional because the stdlib # extractor always ships: this upgrades quality, it does not enable the feature. web = ["trafilatura>=2.2"] - -# LeapSpace (src/leapspace) heavier dependencies. The package itself ships -# with the leapflow distribution, but PyQt6/cua-sandbox/pydantic are only -# installed with this group. Installed with -# `uv sync --all-extras --group leapspace`. -[dependency-groups] +# LeapSpace (src/leapspace) — the optional CUA-sandbox app environment and +# evaluation harness. Its code ships inside the leapflow distribution, but the +# heavy PyQt6/cua-sandbox/pydantic stack is opt-in through this extra so the +# default install stays light. Install it with +# `pip install 'leapflow[leapspace]'` or `uv sync --extra leapspace`. The +# default `--all-extras` syncs deliberately exclude it (`--no-extra leapspace`, +# see Makefile/CI/setup.sh); `make space-sync` opts back in. leapspace = ["pyqt6>=6.7", "cua-sandbox", "pydantic>=2"] [project.scripts] @@ -81,6 +82,11 @@ include = ["leapflow*", "leapspace*"] "leapflow.dashboard.templates" = ["*.yaml"] "leapflow.dashboard.static" = ["*"] "leapflow.plugins.dsh" = ["*.js"] +# LeapSpace task directories are data, not importable packages (note the +# non-identifier `task-001`). Auto-discovery already ships each task's +# action.py; this keeps its config.yaml beside it so an installed example task +# stays runnable rather than half-packaged. +"leapspace.app_space.tasks.task-001" = ["*.yaml"] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/scripts/setup.sh b/scripts/setup.sh index a3245d4b..63d2419b 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -10,8 +10,8 @@ if ! command -v uv >/dev/null 2>&1; then exit 1 fi -echo "==> Installing Python dependencies (uv sync --all-extras)..." -uv sync --all-extras +echo "==> Installing Python dependencies (uv sync --all-extras --no-extra leapspace)..." +uv sync --all-extras --no-extra leapspace echo "" echo "Setup complete!" diff --git a/src/leapflow/learning/capability_observation.py b/src/leapflow/learning/capability_observation.py index 10407fdc..162d7bb4 100644 --- a/src/leapflow/learning/capability_observation.py +++ b/src/leapflow/learning/capability_observation.py @@ -9,12 +9,43 @@ import time from dataclasses import dataclass, field -from typing import Any, Mapping, Sequence +from typing import Any, Iterable, Mapping, Sequence from leapflow.domain.capability_requirement import CapabilityRequirement from leapflow.domain.environment_fingerprint import EnvironmentFingerprint from leapflow.learning.capability_gap_detector import CapabilityGapDetector +# The evidence origin the observation layer has always accepted. Kept as the +# default so behaviour is unchanged unless a classifier is explicitly supplied. +DEFAULT_ACCEPTED_EVIDENCE = frozenset({"unknown_tool"}) + + +@dataclass(frozen=True) +class CapabilityEvidenceClassifier: + """Decide whether a structured tool result is capability-relevant evidence. + + The shipped observation layer hard-codes ``error_type == "unknown_tool"``, + which is blind to a structural environment change under a still-present tool. + This classifier makes the accepted ``error_type`` set explicit and + configurable so an environment-aware source (e.g. interface-drift / + affordance-loss signals) can feed the same governed pipeline, while the + default set preserves today's behaviour exactly. The accepted set is meant to + be driven by ``environment_adaptation.accepted_evidence_kinds`` config; it is + never inferred from natural-language text. + """ + + accepted: frozenset[str] = DEFAULT_ACCEPTED_EVIDENCE + + @classmethod + def from_kinds(cls, kinds: Iterable[str] | None = None) -> "CapabilityEvidenceClassifier": + """Build from an iterable of accepted error kinds (None -> default).""" + if not kinds: + return cls() + return cls(accepted=frozenset(str(kind) for kind in kinds if str(kind))) + + def accepts(self, result: Mapping[str, Any] | None) -> bool: + return isinstance(result, Mapping) and str(result.get("error_type") or "") in self.accepted + @dataclass(frozen=True) class CapabilityObservation: @@ -32,17 +63,25 @@ class CapabilityObservationBuffer: """Collect structured tool evidence and derive reviewable requirements.""" detector: CapabilityGapDetector = field(default_factory=CapabilityGapDetector) + # Optional evidence gate. ``None`` preserves the shipped behaviour (accept + # only ``unknown_tool``); an explicit classifier widens the accepted set. + classifier: CapabilityEvidenceClassifier | None = None _observations: list[CapabilityObservation] = field(default_factory=list) def add_result(self, result: Mapping[str, Any] | None) -> bool: """Record a structured tool result when it represents a capability gap.""" - if not self._is_supported_signal(result): + if not self._accepts(result): return False self._observations.append( CapabilityObservation(observed_at=time.time(), result=dict(result or {})) ) return True + def _accepts(self, result: Mapping[str, Any] | None) -> bool: + if self.classifier is not None: + return self.classifier.accepts(result) + return self._is_supported_signal(result) + def extend_results(self, results: Sequence[Mapping[str, Any]]) -> int: """Record multiple tool results and return how many were accepted.""" return sum(1 for result in results if self.add_result(result)) @@ -70,9 +109,23 @@ def _is_supported_signal(result: Mapping[str, Any] | None) -> bool: class CapabilityObservationService: """Bridge turn-local observations into durable, cross-turn requirements.""" - def __init__(self, store: Any, *, detector: CapabilityGapDetector | None = None) -> None: + def __init__( + self, + store: Any, + *, + detector: CapabilityGapDetector | None = None, + classifier: CapabilityEvidenceClassifier | None = None, + ) -> None: self._store = store self._detector = detector or CapabilityGapDetector() + # ``None`` preserves the shipped ``unknown_tool``-only gate; an explicit + # classifier lets environment-derived evidence reach the durable store. + self._classifier = classifier + + def _accepts(self, result: Mapping[str, Any] | None) -> bool: + if self._classifier is not None: + return self._classifier.accepts(result) + return CapabilityObservationBuffer._is_supported_signal(result) def observe_result( self, @@ -86,7 +139,7 @@ def observe_result( metadata: Mapping[str, Any] | None = None, ) -> dict[str, Any] | None: """Persist one structured observation, returning the stored record.""" - if not CapabilityObservationBuffer._is_supported_signal(result): + if not self._accepts(result): return None env_payload = ( environment.to_dict() @@ -142,7 +195,9 @@ def requirements( __all__ = [ + "CapabilityEvidenceClassifier", "CapabilityObservation", "CapabilityObservationBuffer", "CapabilityObservationService", + "DEFAULT_ACCEPTED_EVIDENCE", ] diff --git a/src/leapflow/plugins/adaptive_loop.py b/src/leapflow/plugins/adaptive_loop.py index 3e51b987..09827fe2 100644 --- a/src/leapflow/plugins/adaptive_loop.py +++ b/src/leapflow/plugins/adaptive_loop.py @@ -365,6 +365,47 @@ def resolve_once( record=record, ) + def unmet_requirements( + self, + requirements: Sequence[CapabilityRequirement], + environment: EnvironmentFingerprint, + *, + candidate_filter: CandidateFilter | None = None, + scorers: Any = None, + ) -> tuple[CapabilityRequirement, ...]: + """Resolution-first gap gate: return only requirements the live registry + cannot already satisfy. + + Shipped adaptive evolution has no short-circuit between "a requirement + exists" and "propose a plugin", so it can generate and install a + capability the live catalog already provides. This method resolves each + requirement against the current registry first; a requirement whose best + candidate is eligible is *not* a gap and is excluded from the result. Only + the returned (unmet) requirements should enter the proposal queue. + + Read with respect to the capability set: it performs no install, remove, + or publish. It does call ``assemble()``, which is idempotent and is the + same precondition ``candidates_from_registry`` already requires; on a + registry that has never been assembled this performs the one-time index + and version bump any first read triggers, so a caller that needs a + strictly untouched version counter should assemble beforehand. Callers + that resolve against a task environment pass the environment-aware + ``scorers`` (e.g. including ``EnvironmentAffordanceScorer``); ``None`` uses + the resolver's default scorers. + """ + self._registry.assemble() + candidates = tuple(candidates_from_registry(self._registry)) + if candidate_filter is not None: + candidates = tuple(c for c in candidates if candidate_filter(c)) + resolver = CapabilityResolver(scorers) if scorers is not None else self._resolver + context = ResolverContext( + environment=environment, + trust_ledger=self._trust_ledger, + usage_tracker=self._usage_tracker, + ) + resolutions = resolver.resolve_all(tuple(requirements), candidates, context) + return tuple(r.requirement for r in resolutions if r.unmet) + async def run(self, request: AdaptiveLoopRequest) -> AdaptiveLoopResult: """Resolve, optionally mutate the registry, and resolve again.""" loop_id = request.resolved_loop_id diff --git a/src/leapflow/plugins/capability_plan.py b/src/leapflow/plugins/capability_plan.py index 92b37d3e..269df381 100644 --- a/src/leapflow/plugins/capability_plan.py +++ b/src/leapflow/plugins/capability_plan.py @@ -161,10 +161,23 @@ def executable(self) -> bool: """Return whether the plan has no missing deps and no dependency cycle.""" return not self.missing_dependencies and not self.cycle_detected + @property + def is_actionable(self) -> bool: + """Return whether the plan is executable AND has at least one step. + + ``executable`` is vacuously true for an empty plan (no missing deps, no + cycle), which reads as success on the capability board when in fact + nothing was selected. ``is_actionable`` is the honest signal: a plan that + can actually do something. New surfaces should bind to this; ``executable`` + is retained unchanged for backward compatibility. + """ + return self.executable and bool(self.steps) + def to_dict(self) -> dict[str, Any]: return { "plan_id": self.plan_id, "executable": self.executable, + "is_actionable": self.is_actionable, "cycle_detected": self.cycle_detected, "missing_dependencies": [m.to_dict() for m in self.missing_dependencies], "steps": [s.to_dict() for s in self.steps], diff --git a/src/leapflow/plugins/capability_resolver.py b/src/leapflow/plugins/capability_resolver.py index 29474456..f0567995 100644 --- a/src/leapflow/plugins/capability_resolver.py +++ b/src/leapflow/plugins/capability_resolver.py @@ -46,6 +46,7 @@ class CapabilityCandidate: provides_capabilities: tuple[str, ...] = field(default_factory=tuple) requires_capabilities: tuple[str, ...] = field(default_factory=tuple) requires_platform_capabilities: tuple[str, ...] = field(default_factory=tuple) + requires_environment_affordances: tuple[str, ...] = field(default_factory=tuple) risk_level: str = "read_only" requires_approval: bool = False mutates_state: bool = False @@ -71,6 +72,10 @@ def from_tool(cls, plugin_id: str, tool: ToolMetadata) -> "CapabilityCandidate": tool.requires_platform_capabilities or tuple(raw.get("requires_platform_capabilities") or ()) ), + requires_environment_affordances=_as_tuple( + getattr(tool, "requires_environment_affordances", ()) + or tuple(raw.get("requires_environment_affordances") or ()) + ), risk_level=str(raw.get("risk_level") or "read_only"), requires_approval=bool(raw.get("requires_approval", False)), mutates_state=bool(tool.mutates_state or raw.get("mutates_state", False)), @@ -85,6 +90,7 @@ def to_dict(self) -> dict[str, Any]: "provides_capabilities": list(self.provides_capabilities), "requires_capabilities": list(self.requires_capabilities), "requires_platform_capabilities": list(self.requires_platform_capabilities), + "requires_environment_affordances": list(self.requires_environment_affordances), "risk_level": self.risk_level, "requires_approval": self.requires_approval, "mutates_state": self.mutates_state, @@ -278,6 +284,49 @@ def score( ) +class EnvironmentAffordanceScorer: + """Exclude a candidate whose declared app-level affordances the task + environment does not offer. + + Mirrors ``EnvironmentFitScorer`` but reads the candidate's + ``requires_environment_affordances`` (task/app-level) rather than its host + ``requires_platform_capabilities``. The two are separate declarations so a + tool that needs ``ui.chat.send.v2`` is excluded when the app presents v1, + without conflating that with a host capability. Not in ``_DEFAULT_SCORERS``: + it is injected explicitly (``CapabilityResolver(scorers=...)``) by callers + that resolve against a task environment, so default resolution is unchanged. + """ + + name = "environment_affordance" + + def score( + self, + requirement: CapabilityRequirement, + candidate: CapabilityCandidate, + context: ResolverContext, + ) -> ScoreComponent: + required = candidate.requires_environment_affordances + missing = tuple( + affordance + for affordance in required + if not context.environment.supports_capability(affordance) + ) + if missing: + return ScoreComponent( + self.name, + 0.0, + context.weights.environment_fit, + "missing environment affordances: " + ", ".join(missing), + excluded=True, + ) + return ScoreComponent( + self.name, + 1.0, + context.weights.environment_fit, + "all required environment affordances are present", + ) + + class RiskCostScorer: name = "risk_cost" diff --git a/src/leapflow/plugins/protocol.py b/src/leapflow/plugins/protocol.py index 0131b045..a0dfbed7 100644 --- a/src/leapflow/plugins/protocol.py +++ b/src/leapflow/plugins/protocol.py @@ -88,6 +88,14 @@ class ToolMetadata: provides_capabilities: tuple[str, ...] = () requires_capabilities: tuple[str, ...] = () requires_platform_capabilities: tuple[str, ...] = () + # ``requires_environment_affordances`` are task-environment (app-level) + # preconditions the tool drives -- e.g. ``ui.chat.send.v2`` -- as opposed to + # host ``requires_platform_capabilities`` (e.g. ``shell.exec``). Separating the + # two lets the resolver score a candidate against the *task environment* an + # app presents without conflating it with the host the agent runs on, and lets + # the same tool be compared across app adapters. Defaults empty: a tool that + # depends on no named app affordance declares nothing. + requires_environment_affordances: tuple[str, ...] = () def to_openai_schema(self) -> dict[str, Any]: """Generate OpenAI function-calling schema dict. @@ -107,6 +115,11 @@ def to_openai_schema(self) -> dict[str, Any]: x_leapflow.setdefault( "requires_platform_capabilities", list(self.requires_platform_capabilities) ) + if self.requires_environment_affordances: + x_leapflow.setdefault( + "requires_environment_affordances", + list(self.requires_environment_affordances), + ) entry: dict[str, Any] = { "type": "function", "function": { diff --git a/src/leapspace/__init__.py b/src/leapspace/__init__.py index ba16076f..ac6cd9d7 100644 --- a/src/leapspace/__init__.py +++ b/src/leapspace/__init__.py @@ -3,6 +3,6 @@ The code lives in the ``leapspace.app_space`` submodule (actor, task config, action lint, and the scenario apps). Ships with the leapflow distribution (importable via the editable install); heavier dependencies live in the root -``pyproject.toml`` under the ``leapspace`` dependency group -(``make space-sync``). +``pyproject.toml`` under the ``leapspace`` extra +(``pip install 'leapflow[leapspace]'``, or ``make space-sync``). """ diff --git a/src/leapspace/app_space/__init__.py b/src/leapspace/app_space/__init__.py index e8b1841b..75b5f810 100644 --- a/src/leapspace/app_space/__init__.py +++ b/src/leapspace/app_space/__init__.py @@ -1,6 +1,6 @@ """app_space — the LeapSpace core: actor, task config, lint, and the apps. Kept import-light on purpose: ``leapspace.app_space`` itself must import -without the ``leapspace`` dependency group (PyQt6 / cua-sandbox / pydantic); +without the ``leapspace`` extra (PyQt6 / cua-sandbox / pydantic); only the submodules that need those dependencies pull them in. """ diff --git a/tests/leapspace/test_actor.py b/tests/leapspace/test_actor.py index 2036957d..99c27c61 100644 --- a/tests/leapspace/test_actor.py +++ b/tests/leapspace/test_actor.py @@ -2,7 +2,7 @@ import pytest -pytest.importorskip("cua_sandbox") # leapspace dependency group only +pytest.importorskip("cua_sandbox") # leapspace extra only from cua_sandbox.interfaces.shell import CommandResult diff --git a/tests/leapspace/test_base.py b/tests/leapspace/test_base.py index 479428d9..195ae314 100644 --- a/tests/leapspace/test_base.py +++ b/tests/leapspace/test_base.py @@ -7,7 +7,7 @@ import pytest # noqa: E402 -pytest.importorskip("PyQt6") # leapspace dependency group only +pytest.importorskip("PyQt6") # leapspace extra only from PyQt6.QtWidgets import QApplication, QLabel, QLineEdit, QPushButton, QWidget # noqa: E402 diff --git a/tests/leapspace/test_harness.py b/tests/leapspace/test_harness.py index 5e9faa52..d5f076a3 100644 --- a/tests/leapspace/test_harness.py +++ b/tests/leapspace/test_harness.py @@ -10,6 +10,9 @@ from pathlib import Path import pytest + +pytest.importorskip("cua_sandbox") # leapspace extra only + from cua_sandbox.interfaces.shell import CommandResult import leapspace.app_space.harness as harness_module diff --git a/tests/leapspace/test_signal.py b/tests/leapspace/test_signal.py index d56c35d8..140b28ad 100644 --- a/tests/leapspace/test_signal.py +++ b/tests/leapspace/test_signal.py @@ -4,6 +4,10 @@ import json from types import SimpleNamespace +import pytest + +pytest.importorskip("cua_sandbox") # leapspace extra only (signal pulls in app_space.utils) + import leapspace.app_space.signal as signal_module from leapspace.app_space.signal import ( RECORD_DONE_FILE, diff --git a/tests/leapspace/test_utils.py b/tests/leapspace/test_utils.py index 4bceab8b..62cfb5c5 100644 --- a/tests/leapspace/test_utils.py +++ b/tests/leapspace/test_utils.py @@ -5,6 +5,8 @@ import pytest +pytest.importorskip("cua_sandbox") # leapspace extra only (utils imports cua_sandbox) + from leapspace.app_space.utils import ( check, get_image_venv_python, diff --git a/uv.lock b/uv.lock index 32f27d82..9a084c8d 100644 --- a/uv.lock +++ b/uv.lock @@ -752,8 +752,8 @@ name = "ewmhlib" version = "0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-xlib" }, - { name = "typing-extensions" }, + { name = "python-xlib", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/2f/3a/46ca34abf0725a754bc44ef474ad34aedcc3ea23b052d97b18b76715a6a9/EWMHlib-0.2-py3-none-any.whl", hash = "sha256:f5b07d8cfd4c7734462ee744c32d490f2f3233fa7ab354240069344208d2f6f5", size = 46657, upload-time = "2024-04-17T08:15:56.338Z" }, @@ -1230,21 +1230,20 @@ dev = [ hub = [ { name = "modelscope-hub" }, ] -web = [ - { name = "trafilatura" }, -] - -[package.dev-dependencies] leapspace = [ { name = "cua-sandbox" }, { name = "pydantic" }, { name = "pyqt6" }, ] +web = [ + { name = "trafilatura" }, +] [package.metadata] requires-dist = [ { name = "aiohttp", marker = "extra == 'dashboard'", specifier = ">=3.9" }, { name = "cryptography", specifier = ">=42.0" }, + { name = "cua-sandbox", marker = "extra == 'leapspace'" }, { name = "duckdb", specifier = ">=1.0.0" }, { name = "gnureadline", marker = "sys_platform == 'darwin'", specifier = ">=8.0" }, { name = "httpx", specifier = ">=0.27" }, @@ -1254,8 +1253,10 @@ requires-dist = [ { name = "openai", specifier = ">=1.40" }, { name = "pillow", specifier = ">=10.0" }, { name = "prompt-toolkit", specifier = ">=3.0.40" }, + { name = "pydantic", marker = "extra == 'leapspace'", specifier = ">=2" }, { name = "pynput", specifier = ">=1.8.0" }, { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'", specifier = ">=12.2" }, + { name = "pyqt6", marker = "extra == 'leapspace'", specifier = ">=6.7" }, { name = "pyreadline3", marker = "sys_platform == 'win32'", specifier = ">=3.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, @@ -1267,14 +1268,7 @@ requires-dist = [ { name = "trafilatura", marker = "extra == 'web'", specifier = ">=2.2" }, { name = "watchdog", specifier = ">=3.0" }, ] -provides-extras = ["dev", "hub", "dashboard", "web"] - -[package.metadata.requires-dev] -leapspace = [ - { name = "cua-sandbox" }, - { name = "pydantic", specifier = ">=2" }, - { name = "pyqt6", specifier = ">=6.7" }, -] +provides-extras = ["dev", "hub", "dashboard", "web", "leapspace"] [[package]] name = "lxml" @@ -2174,167 +2168,167 @@ name = "pyobjc" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-accessibility", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-accounts", marker = "platform_release >= '12.0'" }, - { name = "pyobjc-framework-addressbook" }, - { name = "pyobjc-framework-adservices", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-adsupport", marker = "platform_release >= '18.0'" }, - { name = "pyobjc-framework-applescriptkit" }, - { name = "pyobjc-framework-applescriptobjc", marker = "platform_release >= '10.0'" }, - { name = "pyobjc-framework-applicationservices" }, - { name = "pyobjc-framework-apptrackingtransparency", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-arkit", marker = "platform_release >= '25.0'" }, - { name = "pyobjc-framework-audiovideobridging", marker = "platform_release >= '12.0'" }, - { name = "pyobjc-framework-authenticationservices", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-automaticassessmentconfiguration", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-automator" }, - { name = "pyobjc-framework-avfoundation", marker = "platform_release >= '11.0'" }, - { name = "pyobjc-framework-avkit", marker = "platform_release >= '13.0'" }, - { name = "pyobjc-framework-avrouting", marker = "platform_release >= '22.0'" }, - { name = "pyobjc-framework-backgroundassets", marker = "platform_release >= '22.0'" }, - { name = "pyobjc-framework-browserenginekit", marker = "platform_release >= '23.4'" }, - { name = "pyobjc-framework-businesschat", marker = "platform_release >= '18.0'" }, - { name = "pyobjc-framework-calendarstore", marker = "platform_release >= '9.0'" }, - { name = "pyobjc-framework-callkit", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-carbon" }, - { name = "pyobjc-framework-cfnetwork" }, - { name = "pyobjc-framework-cinematic", marker = "platform_release >= '23.0'" }, - { name = "pyobjc-framework-classkit", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-cloudkit", marker = "platform_release >= '14.0'" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-collaboration", marker = "platform_release >= '9.0'" }, - { name = "pyobjc-framework-colorsync", marker = "platform_release >= '17.0'" }, - { name = "pyobjc-framework-compositorservices", marker = "platform_release >= '25.0'" }, - { name = "pyobjc-framework-contacts", marker = "platform_release >= '15.0'" }, - { name = "pyobjc-framework-contactsui", marker = "platform_release >= '15.0'" }, - { name = "pyobjc-framework-coreaudio" }, - { name = "pyobjc-framework-coreaudiokit" }, - { name = "pyobjc-framework-corebluetooth", marker = "platform_release >= '14.0'" }, - { name = "pyobjc-framework-coredata" }, - { name = "pyobjc-framework-corehaptics", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-corelocation", marker = "platform_release >= '10.0'" }, - { name = "pyobjc-framework-coremedia", marker = "platform_release >= '11.0'" }, - { name = "pyobjc-framework-coremediaio", marker = "platform_release >= '11.0'" }, - { name = "pyobjc-framework-coremidi" }, - { name = "pyobjc-framework-coreml", marker = "platform_release >= '17.0'" }, - { name = "pyobjc-framework-coremotion", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-coreservices" }, - { name = "pyobjc-framework-corespotlight", marker = "platform_release >= '17.0'" }, - { name = "pyobjc-framework-coretext" }, - { name = "pyobjc-framework-corewlan", marker = "platform_release >= '10.0'" }, - { name = "pyobjc-framework-cryptotokenkit", marker = "platform_release >= '14.0'" }, - { name = "pyobjc-framework-datadetection", marker = "platform_release >= '21.0'" }, - { name = "pyobjc-framework-devicecheck", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-devicediscoveryextension", marker = "platform_release >= '24.0'" }, - { name = "pyobjc-framework-dictionaryservices", marker = "platform_release >= '9.0'" }, - { name = "pyobjc-framework-discrecording" }, - { name = "pyobjc-framework-discrecordingui" }, - { name = "pyobjc-framework-diskarbitration" }, - { name = "pyobjc-framework-dvdplayback" }, - { name = "pyobjc-framework-eventkit", marker = "platform_release >= '12.0'" }, - { name = "pyobjc-framework-exceptionhandling" }, - { name = "pyobjc-framework-executionpolicy", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-extensionkit", marker = "platform_release >= '22.0'" }, - { name = "pyobjc-framework-externalaccessory", marker = "platform_release >= '17.0'" }, - { name = "pyobjc-framework-fileprovider", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-fileproviderui", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-findersync", marker = "platform_release >= '14.0'" }, - { name = "pyobjc-framework-fsevents", marker = "platform_release >= '9.0'" }, - { name = "pyobjc-framework-fskit", marker = "platform_release >= '24.4'" }, - { name = "pyobjc-framework-gamecenter", marker = "platform_release >= '12.0'" }, - { name = "pyobjc-framework-gamecontroller", marker = "platform_release >= '13.0'" }, - { name = "pyobjc-framework-gamekit", marker = "platform_release >= '12.0'" }, - { name = "pyobjc-framework-gameplaykit", marker = "platform_release >= '15.0'" }, - { name = "pyobjc-framework-gamesave", marker = "platform_release >= '25.0'" }, - { name = "pyobjc-framework-healthkit", marker = "platform_release >= '22.0'" }, - { name = "pyobjc-framework-imagecapturecore", marker = "platform_release >= '10.0'" }, - { name = "pyobjc-framework-inputmethodkit", marker = "platform_release >= '9.0'" }, - { name = "pyobjc-framework-installerplugins" }, - { name = "pyobjc-framework-instantmessage", marker = "platform_release >= '9.0'" }, - { name = "pyobjc-framework-intents", marker = "platform_release >= '16.0'" }, - { name = "pyobjc-framework-intentsui", marker = "platform_release >= '21.0'" }, - { name = "pyobjc-framework-iobluetooth" }, - { name = "pyobjc-framework-iobluetoothui" }, - { name = "pyobjc-framework-iosurface", marker = "platform_release >= '10.0'" }, - { name = "pyobjc-framework-ituneslibrary", marker = "platform_release >= '10.0'" }, - { name = "pyobjc-framework-kernelmanagement", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-latentsemanticmapping" }, - { name = "pyobjc-framework-launchservices" }, - { name = "pyobjc-framework-libdispatch", marker = "platform_release >= '12.0'" }, - { name = "pyobjc-framework-libxpc", marker = "platform_release >= '12.0'" }, - { name = "pyobjc-framework-linkpresentation", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-localauthentication", marker = "platform_release >= '14.0'" }, - { name = "pyobjc-framework-localauthenticationembeddedui", marker = "platform_release >= '21.0'" }, - { name = "pyobjc-framework-mailkit", marker = "platform_release >= '21.0'" }, - { name = "pyobjc-framework-mapkit", marker = "platform_release >= '13.0'" }, - { name = "pyobjc-framework-mediaaccessibility", marker = "platform_release >= '13.0'" }, - { name = "pyobjc-framework-mediaextension", marker = "platform_release >= '24.0'" }, - { name = "pyobjc-framework-medialibrary", marker = "platform_release >= '13.0'" }, - { name = "pyobjc-framework-mediaplayer", marker = "platform_release >= '16.0'" }, - { name = "pyobjc-framework-mediatoolbox", marker = "platform_release >= '13.0'" }, - { name = "pyobjc-framework-metal", marker = "platform_release >= '15.0'" }, - { name = "pyobjc-framework-metalfx", marker = "platform_release >= '22.0'" }, - { name = "pyobjc-framework-metalkit", marker = "platform_release >= '15.0'" }, - { name = "pyobjc-framework-metalperformanceshaders", marker = "platform_release >= '17.0'" }, - { name = "pyobjc-framework-metalperformanceshadersgraph", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-metrickit", marker = "platform_release >= '21.0'" }, - { name = "pyobjc-framework-mlcompute", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-modelio", marker = "platform_release >= '15.0'" }, - { name = "pyobjc-framework-multipeerconnectivity", marker = "platform_release >= '14.0'" }, - { name = "pyobjc-framework-naturallanguage", marker = "platform_release >= '18.0'" }, - { name = "pyobjc-framework-netfs", marker = "platform_release >= '10.0'" }, - { name = "pyobjc-framework-network", marker = "platform_release >= '18.0'" }, - { name = "pyobjc-framework-networkextension", marker = "platform_release >= '15.0'" }, - { name = "pyobjc-framework-notificationcenter", marker = "platform_release >= '14.0'" }, - { name = "pyobjc-framework-opendirectory", marker = "platform_release >= '10.0'" }, - { name = "pyobjc-framework-osakit" }, - { name = "pyobjc-framework-oslog", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-passkit", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-pencilkit", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-phase", marker = "platform_release >= '21.0'" }, - { name = "pyobjc-framework-photos", marker = "platform_release >= '15.0'" }, - { name = "pyobjc-framework-photosui", marker = "platform_release >= '15.0'" }, - { name = "pyobjc-framework-preferencepanes" }, - { name = "pyobjc-framework-pushkit", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-quartz" }, - { name = "pyobjc-framework-quicklookthumbnailing", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-replaykit", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-safariservices", marker = "platform_release >= '16.0'" }, - { name = "pyobjc-framework-safetykit", marker = "platform_release >= '22.0'" }, - { name = "pyobjc-framework-scenekit", marker = "platform_release >= '11.0'" }, - { name = "pyobjc-framework-screencapturekit", marker = "platform_release >= '21.4'" }, - { name = "pyobjc-framework-screensaver" }, - { name = "pyobjc-framework-screentime", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-scriptingbridge", marker = "platform_release >= '9.0'" }, - { name = "pyobjc-framework-searchkit" }, - { name = "pyobjc-framework-security" }, - { name = "pyobjc-framework-securityfoundation" }, - { name = "pyobjc-framework-securityinterface" }, - { name = "pyobjc-framework-securityui", marker = "platform_release >= '24.4'" }, - { name = "pyobjc-framework-sensitivecontentanalysis", marker = "platform_release >= '23.0'" }, - { name = "pyobjc-framework-servicemanagement", marker = "platform_release >= '10.0'" }, - { name = "pyobjc-framework-sharedwithyou", marker = "platform_release >= '22.0'" }, - { name = "pyobjc-framework-sharedwithyoucore", marker = "platform_release >= '22.0'" }, - { name = "pyobjc-framework-shazamkit", marker = "platform_release >= '21.0'" }, - { name = "pyobjc-framework-social", marker = "platform_release >= '12.0'" }, - { name = "pyobjc-framework-soundanalysis", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-speech", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-spritekit", marker = "platform_release >= '13.0'" }, - { name = "pyobjc-framework-storekit", marker = "platform_release >= '11.0'" }, - { name = "pyobjc-framework-symbols", marker = "platform_release >= '23.0'" }, - { name = "pyobjc-framework-syncservices" }, - { name = "pyobjc-framework-systemconfiguration" }, - { name = "pyobjc-framework-systemextensions", marker = "platform_release >= '19.0'" }, - { name = "pyobjc-framework-threadnetwork", marker = "platform_release >= '22.0'" }, - { name = "pyobjc-framework-uniformtypeidentifiers", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-usernotifications", marker = "platform_release >= '18.0'" }, - { name = "pyobjc-framework-usernotificationsui", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-videosubscriberaccount", marker = "platform_release >= '18.0'" }, - { name = "pyobjc-framework-videotoolbox", marker = "platform_release >= '12.0'" }, - { name = "pyobjc-framework-virtualization", marker = "platform_release >= '20.0'" }, - { name = "pyobjc-framework-vision", marker = "platform_release >= '17.0'" }, - { name = "pyobjc-framework-webkit" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-accessibility", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-accounts", marker = "platform_release >= '12.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-addressbook", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-adservices", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-adsupport", marker = "platform_release >= '18.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-applescriptkit", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-applescriptobjc", marker = "platform_release >= '10.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-applicationservices", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-apptrackingtransparency", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-arkit", marker = "platform_release >= '25.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-audiovideobridging", marker = "platform_release >= '12.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-authenticationservices", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-automaticassessmentconfiguration", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-automator", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-avfoundation", marker = "platform_release >= '11.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-avkit", marker = "platform_release >= '13.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-avrouting", marker = "platform_release >= '22.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-backgroundassets", marker = "platform_release >= '22.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-browserenginekit", marker = "platform_release >= '23.4' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-businesschat", marker = "platform_release >= '18.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-calendarstore", marker = "platform_release >= '9.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-callkit", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-carbon", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cfnetwork", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cinematic", marker = "platform_release >= '23.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-classkit", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-cloudkit", marker = "platform_release >= '14.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-collaboration", marker = "platform_release >= '9.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-colorsync", marker = "platform_release >= '17.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-compositorservices", marker = "platform_release >= '25.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-contacts", marker = "platform_release >= '15.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-contactsui", marker = "platform_release >= '15.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-coreaudio", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coreaudiokit", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-corebluetooth", marker = "platform_release >= '14.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-coredata", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-corehaptics", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-corelocation", marker = "platform_release >= '10.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-coremedia", marker = "platform_release >= '11.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-coremediaio", marker = "platform_release >= '11.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-coremidi", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coreml", marker = "platform_release >= '17.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-coremotion", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-coreservices", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-corespotlight", marker = "platform_release >= '17.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-coretext", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-corewlan", marker = "platform_release >= '10.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-cryptotokenkit", marker = "platform_release >= '14.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-datadetection", marker = "platform_release >= '21.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-devicecheck", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-devicediscoveryextension", marker = "platform_release >= '24.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-dictionaryservices", marker = "platform_release >= '9.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-discrecording", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-discrecordingui", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-diskarbitration", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-dvdplayback", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-eventkit", marker = "platform_release >= '12.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-exceptionhandling", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-executionpolicy", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-extensionkit", marker = "platform_release >= '22.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-externalaccessory", marker = "platform_release >= '17.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-fileprovider", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-fileproviderui", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-findersync", marker = "platform_release >= '14.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-fsevents", marker = "platform_release >= '9.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-fskit", marker = "platform_release >= '24.4' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-gamecenter", marker = "platform_release >= '12.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-gamecontroller", marker = "platform_release >= '13.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-gamekit", marker = "platform_release >= '12.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-gameplaykit", marker = "platform_release >= '15.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-gamesave", marker = "platform_release >= '25.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-healthkit", marker = "platform_release >= '22.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-imagecapturecore", marker = "platform_release >= '10.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-inputmethodkit", marker = "platform_release >= '9.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-installerplugins", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-instantmessage", marker = "platform_release >= '9.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-intents", marker = "platform_release >= '16.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-intentsui", marker = "platform_release >= '21.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-iobluetooth", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-iobluetoothui", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-iosurface", marker = "platform_release >= '10.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-ituneslibrary", marker = "platform_release >= '10.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-kernelmanagement", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-latentsemanticmapping", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-launchservices", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-libdispatch", marker = "platform_release >= '12.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-libxpc", marker = "platform_release >= '12.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-linkpresentation", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-localauthentication", marker = "platform_release >= '14.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-localauthenticationembeddedui", marker = "platform_release >= '21.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-mailkit", marker = "platform_release >= '21.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-mapkit", marker = "platform_release >= '13.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-mediaaccessibility", marker = "platform_release >= '13.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-mediaextension", marker = "platform_release >= '24.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-medialibrary", marker = "platform_release >= '13.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-mediaplayer", marker = "platform_release >= '16.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-mediatoolbox", marker = "platform_release >= '13.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-metal", marker = "platform_release >= '15.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-metalfx", marker = "platform_release >= '22.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-metalkit", marker = "platform_release >= '15.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-metalperformanceshaders", marker = "platform_release >= '17.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-metalperformanceshadersgraph", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-metrickit", marker = "platform_release >= '21.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-mlcompute", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-modelio", marker = "platform_release >= '15.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-multipeerconnectivity", marker = "platform_release >= '14.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-naturallanguage", marker = "platform_release >= '18.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-netfs", marker = "platform_release >= '10.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-network", marker = "platform_release >= '18.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-networkextension", marker = "platform_release >= '15.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-notificationcenter", marker = "platform_release >= '14.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-opendirectory", marker = "platform_release >= '10.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-osakit", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-oslog", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-passkit", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-pencilkit", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-phase", marker = "platform_release >= '21.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-photos", marker = "platform_release >= '15.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-photosui", marker = "platform_release >= '15.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-preferencepanes", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-pushkit", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quicklookthumbnailing", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-replaykit", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-safariservices", marker = "platform_release >= '16.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-safetykit", marker = "platform_release >= '22.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-scenekit", marker = "platform_release >= '11.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-screencapturekit", marker = "platform_release >= '21.4' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-screensaver", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-screentime", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-scriptingbridge", marker = "platform_release >= '9.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-searchkit", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-security", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-securityfoundation", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-securityinterface", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-securityui", marker = "platform_release >= '24.4' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-sensitivecontentanalysis", marker = "platform_release >= '23.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-servicemanagement", marker = "platform_release >= '10.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-sharedwithyou", marker = "platform_release >= '22.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-sharedwithyoucore", marker = "platform_release >= '22.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-shazamkit", marker = "platform_release >= '21.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-social", marker = "platform_release >= '12.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-soundanalysis", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-speech", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-spritekit", marker = "platform_release >= '13.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-storekit", marker = "platform_release >= '11.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-symbols", marker = "platform_release >= '23.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-syncservices", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-systemconfiguration", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-systemextensions", marker = "platform_release >= '19.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-threadnetwork", marker = "platform_release >= '22.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-uniformtypeidentifiers", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-usernotifications", marker = "platform_release >= '18.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-usernotificationsui", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-videosubscriberaccount", marker = "platform_release >= '18.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-videotoolbox", marker = "platform_release >= '12.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-virtualization", marker = "platform_release >= '20.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-vision", marker = "platform_release >= '17.0' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-webkit", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/73/ef/b4e64fe87051e72608ed4134072e832e9eae28d97e9c9bb0870f01a18ac5/pyobjc-12.2.1.tar.gz", hash = "sha256:0b2cf49d24213e7604620c31863e7b4e42770c4442c10e59b18ad951cd200cd3", size = 12148, upload-time = "2026-06-19T16:19:38.283Z" } wheels = [ @@ -2362,9 +2356,9 @@ name = "pyobjc-framework-accessibility" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/4b/b0a0f0183b359a07663ed31a3f790986cf880bda909b623fead15cb2afbd/pyobjc_framework_accessibility-12.2.1.tar.gz", hash = "sha256:1e0ad06b5b6ae623f443d15c11780f97908d5c41fdb79532e96c6a4a76066fd8", size = 34377, upload-time = "2026-06-19T16:19:40.592Z" } wheels = [ @@ -2383,8 +2377,8 @@ name = "pyobjc-framework-accounts" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/08/14/6086edbaeb0f48ac1b915d38d11a36efd8de918277da86abc2058a50baad/pyobjc_framework_accounts-12.2.1.tar.gz", hash = "sha256:6e6d603e10182238cd77596380262a38cbb0a9141d1eca6bf522b2213c6d6751", size = 16209, upload-time = "2026-06-19T16:19:41.451Z" } wheels = [ @@ -2396,8 +2390,8 @@ name = "pyobjc-framework-addressbook" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/20/70dad64d397f9ba513a314ad4d1e1f7e4903c21caee98dfab2f7a39aaedb/pyobjc_framework_addressbook-12.2.1.tar.gz", hash = "sha256:bb113fd5bcae93da00d67bf870704d2cfb73da49c4a281249d8a5abf9809aa91", size = 47685, upload-time = "2026-06-19T16:19:42.321Z" } wheels = [ @@ -2416,8 +2410,8 @@ name = "pyobjc-framework-adservices" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/de/6e9fa436a7aacaebe664c918dabfad14a19aa0f6ccaf24384d0c0b55b6f0/pyobjc_framework_adservices-12.2.1.tar.gz", hash = "sha256:6668fbef1b383c5cafae8479f4a8b53e824bd4d568611a53a3075e8c6dc4e39a", size = 12269, upload-time = "2026-06-19T16:19:43.102Z" } wheels = [ @@ -2429,8 +2423,8 @@ name = "pyobjc-framework-adsupport" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/53/d73eafbc080b7eed68917f6a62758dafca80c7a6ac98ef35660185f8ea89/pyobjc_framework_adsupport-12.2.1.tar.gz", hash = "sha256:c659ac447b1bb3b1a54add100d72932cfd50482384a97c22926d281c9d97a6c0", size = 12098, upload-time = "2026-06-19T16:19:43.985Z" } wheels = [ @@ -2442,8 +2436,8 @@ name = "pyobjc-framework-applescriptkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b4/04/187611b7f3e51532b45df08c3b22edd53f163f337a88d7c03c4dc904e2ed/pyobjc_framework_applescriptkit-12.2.1.tar.gz", hash = "sha256:fa3a55933ec090aebc695f3575a5afe8a2d37015162cd30e6c00948748d08f83", size = 11668, upload-time = "2026-06-19T16:19:44.68Z" } wheels = [ @@ -2455,8 +2449,8 @@ name = "pyobjc-framework-applescriptobjc" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/b8/67273037f4f10334d9660001bf12f5cc8b483f9cf9c8df91aa39701bcce4/pyobjc_framework_applescriptobjc-12.2.1.tar.gz", hash = "sha256:c60b751a6c20148f23eb1d556aa36612c7e39b14e0bd87abaea53744ff8eabc9", size = 11777, upload-time = "2026-06-19T16:19:45.417Z" } wheels = [ @@ -2468,10 +2462,10 @@ name = "pyobjc-framework-applicationservices" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coretext" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coretext", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/4d/0ebdd8144aba94b8fe9828ccee5616a4bf53d1f8bc51cff55f3cce86d695/pyobjc_framework_applicationservices-12.2.1.tar.gz", hash = "sha256:048ea663c9ac75c44a15dc7d5b8d78cbb4c97bf1c76e83835e8d5498e184001f", size = 109342, upload-time = "2026-06-19T16:19:46.149Z" } wheels = [ @@ -2490,8 +2484,8 @@ name = "pyobjc-framework-apptrackingtransparency" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/c3/2f6b30c7010b32450769d679c30393a148d0b1531b31f1c0d3a600fc999b/pyobjc_framework_apptrackingtransparency-12.2.1.tar.gz", hash = "sha256:3eff48469eb07e4637408f410ce2690711f5c3de2fbfaa8844daa718ed3479f7", size = 12795, upload-time = "2026-06-19T16:19:47.034Z" } wheels = [ @@ -2503,8 +2497,8 @@ name = "pyobjc-framework-arkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/be/db21fafd315dc479925d79bd144f132cb38fb37583212753db8252b765d8/pyobjc_framework_arkit-12.2.1.tar.gz", hash = "sha256:ed4f67b1594a427b66ab751657ce6183a93a07ba32d3ba3bbefd7e0b4f6bf64d", size = 40145, upload-time = "2026-06-19T16:19:47.704Z" } wheels = [ @@ -2516,8 +2510,8 @@ name = "pyobjc-framework-audiovideobridging" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/14/6b/cacbe1a5f8e72c76f546689b551853e9312a00a612e8a2087e75f0dc1e3a/pyobjc_framework_audiovideobridging-12.2.1.tar.gz", hash = "sha256:7b6890ebfb1d346988dad7ff20182373c5c15026b1f9a50f64f654b4ff255e76", size = 44241, upload-time = "2026-06-19T16:19:48.555Z" } wheels = [ @@ -2536,8 +2530,8 @@ name = "pyobjc-framework-authenticationservices" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/97/1f/fa7506fb8df1c30f7a1fddc9812705494421b2064391106dc00cac948ce8/pyobjc_framework_authenticationservices-12.2.1.tar.gz", hash = "sha256:da70cd842a41276e6f9958b1d3e227a3de452e696c56f8e2b439add45e578665", size = 75693, upload-time = "2026-06-19T16:19:49.411Z" } wheels = [ @@ -2556,8 +2550,8 @@ name = "pyobjc-framework-automaticassessmentconfiguration" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a5/d1/1124aaf5aa1a35126836c623e30eb7ea47c9e64e758f7c3156aa61dbffbd/pyobjc_framework_automaticassessmentconfiguration-12.2.1.tar.gz", hash = "sha256:6888ec9846d04cb7983525d9a134b838044d9857182fe5404224c3071f4cc64f", size = 24775, upload-time = "2026-06-19T16:19:50.219Z" } wheels = [ @@ -2576,8 +2570,8 @@ name = "pyobjc-framework-automator" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e5/2f/6669c037108799e2319894f21e0067c3119c2a185b18ca70aaf645309199/pyobjc_framework_automator-12.2.1.tar.gz", hash = "sha256:6ea468966d911292d73f52672603eee50bb4a3d651094f63de25dd6d5818d347", size = 188942, upload-time = "2026-06-19T16:19:51.099Z" } wheels = [ @@ -2596,11 +2590,11 @@ name = "pyobjc-framework-avfoundation" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coreaudio" }, - { name = "pyobjc-framework-coremedia" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coreaudio", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coremedia", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2e/26/7616f0bc8e4eaaba948cf5d220c8f55e0f54f617a2812392a82f19c30f39/pyobjc_framework_avfoundation-12.2.1.tar.gz", hash = "sha256:2735e4f1c345d2b533541577e292f3ad2f75d19200eff99f1a2db16d78b4f1a3", size = 410329, upload-time = "2026-06-19T16:19:52.413Z" } wheels = [ @@ -2619,9 +2613,9 @@ name = "pyobjc-framework-avkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/8be6b94ad46e50f7f868b487b98ade01914809cf440d5ec892a16600d5c4/pyobjc_framework_avkit-12.2.1.tar.gz", hash = "sha256:9180734ba1ef34000ee0463727dbb73624cc4610a298447c8200aa8a7515e0b6", size = 33618, upload-time = "2026-06-19T16:19:53.448Z" } wheels = [ @@ -2640,8 +2634,8 @@ name = "pyobjc-framework-avrouting" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3f/74/75a7a33349407c3801fd014cfdfa6ceb3e8e83a020277d5e99eedb3a3728/pyobjc_framework_avrouting-12.2.1.tar.gz", hash = "sha256:8fd237f7a5c8d905f194fcdfeb6771e69c7106fc544c24e9725d952505f0fdc7", size = 20905, upload-time = "2026-06-19T16:19:54.202Z" } wheels = [ @@ -2660,8 +2654,8 @@ name = "pyobjc-framework-backgroundassets" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/92/2b680e44df5d3a81a2431acfbf6eb2e6fba93f1c382651a77040060d4a83/pyobjc_framework_backgroundassets-12.2.1.tar.gz", hash = "sha256:771fc7a45a10a4b4afb5465b1528958078f456629b63c36a7a43505f93acbaea", size = 29376, upload-time = "2026-06-19T16:19:55.028Z" } wheels = [ @@ -2680,11 +2674,11 @@ name = "pyobjc-framework-browserenginekit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coreaudio" }, - { name = "pyobjc-framework-coremedia" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coreaudio", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coremedia", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/45/955a13e96d79e369754a99ad185b59edb721e95196a893f215f299bcb9bc/pyobjc_framework_browserenginekit-12.2.1.tar.gz", hash = "sha256:6e07b9582fb7e9b9a9ea40280d5815e4130cd0f9194fdc6bdd3a3bc07d6d2f85", size = 32607, upload-time = "2026-06-19T16:19:55.949Z" } wheels = [ @@ -2703,8 +2697,8 @@ name = "pyobjc-framework-businesschat" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8a/26/c92176248363ef510e991c7b537de7aaa4c66b232de1d6c7c527270d9911/pyobjc_framework_businesschat-12.2.1.tar.gz", hash = "sha256:2847c422d202fb8e1eb892c7151b2251b2880a5e6618b7affbdec2b5521a6072", size = 12409, upload-time = "2026-06-19T16:19:56.76Z" } wheels = [ @@ -2716,8 +2710,8 @@ name = "pyobjc-framework-calendarstore" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cf/f7/65fe8ddcfd0e88442139b9dd737184aeb6f83d6748315061190ecd0987ad/pyobjc_framework_calendarstore-12.2.1.tar.gz", hash = "sha256:5659f2d59dd49423d3880295cd4b395a61c0b7aea8db654e63dab6dd32d28dee", size = 54448, upload-time = "2026-06-19T16:19:57.485Z" } wheels = [ @@ -2729,8 +2723,8 @@ name = "pyobjc-framework-callkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/0b/0cef47cd370e195c113125205e83443e23a1da838df7419ad9131539b266/pyobjc_framework_callkit-12.2.1.tar.gz", hash = "sha256:66dfbb864c6aa253ff90ac91cdc23dc7158dee8eb76b1764126f2eae59e771a8", size = 32653, upload-time = "2026-06-19T16:19:58.251Z" } wheels = [ @@ -2749,8 +2743,8 @@ name = "pyobjc-framework-carbon" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/07/1f/c5df0b0f276542be355e56541af59ae77e98b22e5265d483601a2324c4e0/pyobjc_framework_carbon-12.2.1.tar.gz", hash = "sha256:a14ca4a45e697c2d187753bc851f3bb49d5bcf66d4ef1d2363fd9962417d8638", size = 39755, upload-time = "2026-06-19T16:19:59.099Z" } wheels = [ @@ -2762,8 +2756,8 @@ name = "pyobjc-framework-cfnetwork" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/96/189e013d494489e6468d9b0b81c4b0e2f338574e1a777b7a43a6b37573e6/pyobjc_framework_cfnetwork-12.2.1.tar.gz", hash = "sha256:cadc9f65a97c20cf839259229c34ecdb5b54a3ade816c43374a1eb25d3900925", size = 47652, upload-time = "2026-06-19T16:20:00.352Z" } wheels = [ @@ -2782,11 +2776,11 @@ name = "pyobjc-framework-cinematic" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-avfoundation" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coremedia" }, - { name = "pyobjc-framework-metal" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-avfoundation", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coremedia", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-metal", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/a942906b8753161e58b89e6d986dc24a435a8cbe6ab6ea80162d31b37895/pyobjc_framework_cinematic-12.2.1.tar.gz", hash = "sha256:cd251ded9393ff4a993a3689d4d3ce7ba3926e7f884f9cd333cf9610338ac28b", size = 24948, upload-time = "2026-06-19T16:20:01.374Z" } wheels = [ @@ -2798,8 +2792,8 @@ name = "pyobjc-framework-classkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/33/d2933e1dcf122be5b785220713c80cf07a24d2e21bd429b39d723059f653/pyobjc_framework_classkit-12.2.1.tar.gz", hash = "sha256:2f14c6056486b274e487deabf2ce94ccf17db5df6cd332617592ff2d306d7b5c", size = 28972, upload-time = "2026-06-19T16:20:02.296Z" } wheels = [ @@ -2818,11 +2812,11 @@ name = "pyobjc-framework-cloudkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-accounts" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coredata" }, - { name = "pyobjc-framework-corelocation" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-accounts", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coredata", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-corelocation", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/3a/ff99bc394e051086f7d63ade09460de85e2540cf823fb1cd759225f2c744/pyobjc_framework_cloudkit-12.2.1.tar.gz", hash = "sha256:7d3810347fe8de6171d8a4377916750b4ba3bfa874b5f8e5bd0ca6e62d2c4f43", size = 71962, upload-time = "2026-06-19T16:20:03.084Z" } wheels = [ @@ -2834,7 +2828,7 @@ name = "pyobjc-framework-cocoa" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } wheels = [ @@ -2853,8 +2847,8 @@ name = "pyobjc-framework-collaboration" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/15/b0/9cd5d547543a87ab199a5dc6e4c489b01b825994b52b0d17d89abd7219c6/pyobjc_framework_collaboration-12.2.1.tar.gz", hash = "sha256:d293b191823cf8c5cc17e74279e640312961a9226da97e6258580d1b6085e1e4", size = 15065, upload-time = "2026-06-19T16:20:06.502Z" } wheels = [ @@ -2866,8 +2860,8 @@ name = "pyobjc-framework-colorsync" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/92/dd/8f292cc041fb2a836a3ee7432c3f64347a15b5b4d64278277e4954ba28e1/pyobjc_framework_colorsync-12.2.1.tar.gz", hash = "sha256:1e682586a319f49d3ee3c93e54a527a1ff93de06f653e77c0c4ff4d9671469f9", size = 26902, upload-time = "2026-06-19T16:20:07.213Z" } wheels = [ @@ -2879,9 +2873,9 @@ name = "pyobjc-framework-compositorservices" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-metal" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-metal", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/f3/2136460770ff0b32e64282eed79c37a07c1dfa9df761f2679462391d4ada/pyobjc_framework_compositorservices-12.2.1.tar.gz", hash = "sha256:683b765077ce3bf9b680bfce23124ace85208738ff3c14768e1ca80fd78c1565", size = 24941, upload-time = "2026-06-19T16:20:08.032Z" } wheels = [ @@ -2893,8 +2887,8 @@ name = "pyobjc-framework-contacts" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4a/e2/a9c33480e4f6de3f20e2d2668ea05520061b8eae31e8461b09940c01f2dc/pyobjc_framework_contacts-12.2.1.tar.gz", hash = "sha256:b009f1e85d672e659c30cefbba02a1825b4ca2b604e65826445fb8620159725e", size = 48701, upload-time = "2026-06-19T16:20:08.856Z" } wheels = [ @@ -2913,9 +2907,9 @@ name = "pyobjc-framework-contactsui" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-contacts" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-contacts", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/22/a01e677c4e44c3e03850c7765b4cc20e3b8fbad53ee92300eddd77dc8627/pyobjc_framework_contactsui-12.2.1.tar.gz", hash = "sha256:6ddfaf3d3f159bc4bb8ccd65414e94b4b6539a5588e84efb01838b19e3b1ba0b", size = 19329, upload-time = "2026-06-19T16:20:09.679Z" } wheels = [ @@ -2934,8 +2928,8 @@ name = "pyobjc-framework-coreaudio" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/df/f1d402bb7b437374f942bb19410a955a74291867c77a920d9c13887d9e48/pyobjc_framework_coreaudio-12.2.1.tar.gz", hash = "sha256:7dfbf1851523aed453af43a628e057d8950d6e020574aa497a2e4f559b6383c8", size = 78690, upload-time = "2026-06-19T16:20:10.586Z" } wheels = [ @@ -2954,9 +2948,9 @@ name = "pyobjc-framework-coreaudiokit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coreaudio" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coreaudio", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/82/622a58a3aff47cfdad8cc3bf1c6a3a0c4d73d1cc3c71d050ac1bc0a62c6a/pyobjc_framework_coreaudiokit-12.2.1.tar.gz", hash = "sha256:61a5b796f8296ca5cb4779ec19391ad3a37f35c0c689a401d6e8d41cbe936f08", size = 20941, upload-time = "2026-06-19T16:20:11.407Z" } wheels = [ @@ -2975,8 +2969,8 @@ name = "pyobjc-framework-corebluetooth" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d4/91/c76f3c5e8e80c7047e43c4c05b3e6fda9a7cefad5aae85487007674c966c/pyobjc_framework_corebluetooth-12.2.1.tar.gz", hash = "sha256:7dbb285295097205bebbcb11f55161e5faa02111108fb7b17536176e31971eb0", size = 37568, upload-time = "2026-06-19T16:20:12.191Z" } wheels = [ @@ -2995,8 +2989,8 @@ name = "pyobjc-framework-coredata" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/cc/62113edb09c6f72922d800ad7dbd2b812e69f5933a245c59f2d33b214a47/pyobjc_framework_coredata-12.2.1.tar.gz", hash = "sha256:f357447b7955cfe5391dac4fe003b79ded307f4f00712dcfaec3d3ecfca30824", size = 143307, upload-time = "2026-06-19T16:20:13.096Z" } wheels = [ @@ -3015,8 +3009,8 @@ name = "pyobjc-framework-corehaptics" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/1b/76c27ae0f86126802c7f82f21c67868467795810750d9d192006471aa965/pyobjc_framework_corehaptics-12.2.1.tar.gz", hash = "sha256:73ce1afcb0174add11fd6f05cc67d8a371802ce8e94ba9d4f65c7cee0f392b0f", size = 24886, upload-time = "2026-06-19T16:20:14.011Z" } wheels = [ @@ -3028,8 +3022,8 @@ name = "pyobjc-framework-corelocation" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b8/93/41d2ae5cf15a27ee1b51e167b538e50aa752597002878556ed49b3615573/pyobjc_framework_corelocation-12.2.1.tar.gz", hash = "sha256:10b3c206049b70cbab0f98b37bcd91ad97de5ab57041b18a60ab702629009a31", size = 60318, upload-time = "2026-06-19T16:20:14.792Z" } wheels = [ @@ -3048,8 +3042,8 @@ name = "pyobjc-framework-coremedia" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e3/79/f501d730a9c320e0b2b3916e95f57e66dd6736d210a1aa5b63eb6c43e605/pyobjc_framework_coremedia-12.2.1.tar.gz", hash = "sha256:71b45f7cd52bd997d836c15a0e1016db90815a219dc87fd20435a6f08b87df7b", size = 98252, upload-time = "2026-06-19T16:20:15.75Z" } wheels = [ @@ -3068,8 +3062,8 @@ name = "pyobjc-framework-coremediaio" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/08/8b/60e73d8049e9c123ff237694acea8752e9e8143864bfea4bab0bebb55db4/pyobjc_framework_coremediaio-12.2.1.tar.gz", hash = "sha256:edfd070544857b8e1d2ae55ed7c7eac9f513b4cd7c03ee79615c7d524e362d62", size = 56604, upload-time = "2026-06-19T16:20:16.744Z" } wheels = [ @@ -3088,8 +3082,8 @@ name = "pyobjc-framework-coremidi" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d2/77/c51599da17f742fdcaaa381a26daadc22c79739dcf7705f8faf29ca1692a/pyobjc_framework_coremidi-12.2.1.tar.gz", hash = "sha256:d9744001102f935646997136c3d7d0562088bafa1837e5ccc439b5c8ee9e032e", size = 63469, upload-time = "2026-06-19T16:20:17.577Z" } wheels = [ @@ -3108,8 +3102,8 @@ name = "pyobjc-framework-coreml" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/1e/7d2db3e4468eb04cc92264be83113d86eea4f96302742437de695a445d6d/pyobjc_framework_coreml-12.2.1.tar.gz", hash = "sha256:ef3c2b6a160891b44173235603d10174929656b9c206d6f2f443fe2aa903c2cb", size = 49272, upload-time = "2026-06-19T16:20:18.459Z" } wheels = [ @@ -3128,8 +3122,8 @@ name = "pyobjc-framework-coremotion" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/d5/15d318ab0d12681ff5aa5c6799287480dff561e2b3a79d2befe1811b0453/pyobjc_framework_coremotion-12.2.1.tar.gz", hash = "sha256:21fd319d7313b9b03f062239f1c09e324969d5da0fe74842c92a2724381bf78e", size = 38049, upload-time = "2026-06-19T16:20:19.483Z" } wheels = [ @@ -3148,9 +3142,9 @@ name = "pyobjc-framework-coreservices" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-fsevents" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-fsevents", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4c/dd/d87ebbb99b1c277a519e1b7e0fb5efaf2e68833322d9c1eca5ecd79ed8b9/pyobjc_framework_coreservices-12.2.1.tar.gz", hash = "sha256:b4f052acd7346afa6f5441d32a19faaf080c3441cfaafad40c9b9a485b664554", size = 399935, upload-time = "2026-06-19T16:20:20.469Z" } wheels = [ @@ -3169,8 +3163,8 @@ name = "pyobjc-framework-corespotlight" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/89479d419712deef7e245a8284edfebc418297a17e3066a990ae88c3bf3d/pyobjc_framework_corespotlight-12.2.1.tar.gz", hash = "sha256:85d6080ff2f3a02593650eeb799d667be66383e8cd947abfec5e8ef8fd10d18b", size = 45685, upload-time = "2026-06-19T16:20:21.45Z" } wheels = [ @@ -3189,9 +3183,9 @@ name = "pyobjc-framework-coretext" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/9c/4c7f452059dc1d3845b8e627b9113c247a997b9b07518e848c2ab7ff3149/pyobjc_framework_coretext-12.2.1.tar.gz", hash = "sha256:af740e784d7c592c34025ec7165f4f6c1a69b5a2d9075f06e41e4f77c212aed2", size = 97349, upload-time = "2026-06-19T16:20:22.508Z" } wheels = [ @@ -3210,8 +3204,8 @@ name = "pyobjc-framework-corewlan" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/4c/1ff3c5042ee2e8344b47978458af62a81ccc49894039fcfdf81d3ced4ee9/pyobjc_framework_corewlan-12.2.1.tar.gz", hash = "sha256:9a7ae402a55710392570a736a2bbe15f372325f941fb7074e682f75e4866c3fe", size = 35515, upload-time = "2026-06-19T16:20:23.401Z" } wheels = [ @@ -3230,8 +3224,8 @@ name = "pyobjc-framework-cryptotokenkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/0f/697f89ccc2b4f6186b126d29d33def5d699ded72746c6c963aae2f8f4107/pyobjc_framework_cryptotokenkit-12.2.1.tar.gz", hash = "sha256:f5ad2a333ff4ba77d2ba901257836b13c1c93e62342dc092fe57dd67a97ceee7", size = 38273, upload-time = "2026-06-19T16:20:24.319Z" } wheels = [ @@ -3250,8 +3244,8 @@ name = "pyobjc-framework-datadetection" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/20/f2/8c2ab85d3dc8022450af30d58b225bfd3e01693f50d383c80e7a905bcd81/pyobjc_framework_datadetection-12.2.1.tar.gz", hash = "sha256:b1059f9bcfab5a96606dfdde663f41dd8c23a33f8bc8c00371d68796476981cc", size = 12679, upload-time = "2026-06-19T16:20:25.331Z" } wheels = [ @@ -3263,8 +3257,8 @@ name = "pyobjc-framework-devicecheck" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/c6/9784a80bf3bbd45b4982d9349280938bf70a0b7e15bccc8302ebf324c379/pyobjc_framework_devicecheck-12.2.1.tar.gz", hash = "sha256:f67a745b89cd2f3e307cabee018a509aff0561e3f747eb4dbfe84498f7f2ca90", size = 13321, upload-time = "2026-06-19T16:20:26.05Z" } wheels = [ @@ -3276,8 +3270,8 @@ name = "pyobjc-framework-devicediscoveryextension" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/af/e5794d11e3e485c194f26563d04bcd4d729335ac221547e6d93109dd070c/pyobjc_framework_devicediscoveryextension-12.2.1.tar.gz", hash = "sha256:ccec236e790304c0bb880035b7f14738346c90d18cc4cb77b35169aa1035051e", size = 15767, upload-time = "2026-06-19T16:20:26.869Z" } wheels = [ @@ -3289,8 +3283,8 @@ name = "pyobjc-framework-dictionaryservices" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-coreservices" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coreservices", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/2d/0c9cc7065a9d2d387be065ad3721b77876627541beee224bf86639c65f61/pyobjc_framework_dictionaryservices-12.2.1.tar.gz", hash = "sha256:631560760d58fe89af8332adee6dcaee35867cf13f4607f7b5c36e85fa4c1db9", size = 10712, upload-time = "2026-06-19T16:20:27.562Z" } wheels = [ @@ -3302,8 +3296,8 @@ name = "pyobjc-framework-discrecording" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/ec/a7be70eb1c6700f446c68530e74bd71e9fde6ca2a5e76b237bae8fa980f1/pyobjc_framework_discrecording-12.2.1.tar.gz", hash = "sha256:2616daba51f50b8c6989d38d502ca98ed3ce53aee6b01c9457534267cbb1a4e2", size = 62013, upload-time = "2026-06-19T16:20:28.243Z" } wheels = [ @@ -3322,9 +3316,9 @@ name = "pyobjc-framework-discrecordingui" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-discrecording" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-discrecording", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/34/be343e4da6765228d1b6c6ac72a0c1c339ed8da931fa2701f28467c14aaa/pyobjc_framework_discrecordingui-12.2.1.tar.gz", hash = "sha256:1cf9e9e028c619f932ecf3ec0efc91227840bf6c6492c788cde91dc5c3744da6", size = 19538, upload-time = "2026-06-19T16:20:29.049Z" } wheels = [ @@ -3336,8 +3330,8 @@ name = "pyobjc-framework-diskarbitration" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/25/d6a3231f7d81903e6cd6dd9674ef798d45dba2cf301dfb2224277e89c62f/pyobjc_framework_diskarbitration-12.2.1.tar.gz", hash = "sha256:b79a44c8a7791109371bb6aa78ee970c7cf0ee6a6ecdaf92ae3b9dcc4f57f469", size = 18174, upload-time = "2026-06-19T16:20:29.842Z" } wheels = [ @@ -3349,8 +3343,8 @@ name = "pyobjc-framework-dvdplayback" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0a/de/281c85dbcde3422af95cb6cf4607abde52612bcaa66850b8c1e630f25152/pyobjc_framework_dvdplayback-12.2.1.tar.gz", hash = "sha256:cc715bce5edceaec078e3b39141a660cb4ca2fbe0afdf133bd2c531c170e45a6", size = 34829, upload-time = "2026-06-19T16:20:30.576Z" } wheels = [ @@ -3362,8 +3356,8 @@ name = "pyobjc-framework-eventkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2b/94/757c963beb0fb86891c3cd16db56d2e1cf746c24d8256023d6d7f4c83ef7/pyobjc_framework_eventkit-12.2.1.tar.gz", hash = "sha256:2528a61da2fed7d71933d7e5407414176cfcac2e03fdba4633633f0d646c75fa", size = 33775, upload-time = "2026-06-19T16:20:31.384Z" } wheels = [ @@ -3375,8 +3369,8 @@ name = "pyobjc-framework-exceptionhandling" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/59/ef544e804de32c5e437b9936742ae2bfdb6873ee1b284609a70e98855220/pyobjc_framework_exceptionhandling-12.2.1.tar.gz", hash = "sha256:aef051e1afda09853289f66d4e6c1b58cd924656afc2a1bec05b15e22e20e5f1", size = 17174, upload-time = "2026-06-19T16:20:32.11Z" } wheels = [ @@ -3388,8 +3382,8 @@ name = "pyobjc-framework-executionpolicy" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b4/9e/c6f4962416713ee46ae5104b08091d3c1ff49fdcb69bf1edc03d2285b7e2/pyobjc_framework_executionpolicy-12.2.1.tar.gz", hash = "sha256:898d38b19e805e12da317930474f49a9f944f7e2335a9dc9c1644ecdd079d12e", size = 13043, upload-time = "2026-06-19T16:20:32.786Z" } wheels = [ @@ -3401,8 +3395,8 @@ name = "pyobjc-framework-extensionkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ee/f3/51d50e7af4958d597d924fab765725746ed164ff02e59928296574f6e2d2/pyobjc_framework_extensionkit-12.2.1.tar.gz", hash = "sha256:9b8dea5867436ecfeefc7edb4cd8358c8e7741d31693047f1321e15dfc533540", size = 19239, upload-time = "2026-06-19T16:20:33.738Z" } wheels = [ @@ -3421,8 +3415,8 @@ name = "pyobjc-framework-externalaccessory" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/75/e2/96f79a29c7bc6c229035dd8e92f4f73001affa8e642248cf7ab5bf03b2c8/pyobjc_framework_externalaccessory-12.2.1.tar.gz", hash = "sha256:49658d55b3401c03ef3523ab3b8e2ed082739e971a0070d81b16d06441ac8d03", size = 22011, upload-time = "2026-06-19T16:20:34.554Z" } wheels = [ @@ -3441,8 +3435,8 @@ name = "pyobjc-framework-fileprovider" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/15/d161117076a478299804b40720c5f110b4540f77ddb1b55e76b18dcc3ea8/pyobjc_framework_fileprovider-12.2.1.tar.gz", hash = "sha256:fd94e8941de50b6bc94ab8fbbf0f4605eca0602e4bd48088b8d6c1162115b506", size = 50576, upload-time = "2026-06-19T16:20:35.382Z" } wheels = [ @@ -3461,8 +3455,8 @@ name = "pyobjc-framework-fileproviderui" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-fileprovider" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-fileprovider", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/77/4c/066d39b6d90f637fdb2d6ab8762ffb234b700e89e93cb7473729b49aa505/pyobjc_framework_fileproviderui-12.2.1.tar.gz", hash = "sha256:40d02bcb15e324af6c624c85f1d65d83f81f31dd72136b0e5ec87440dc0fba4c", size = 12863, upload-time = "2026-06-19T16:20:36.391Z" } wheels = [ @@ -3474,8 +3468,8 @@ name = "pyobjc-framework-findersync" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8f/8d/344b385f233b5843f05e7b78bea125123e22bc4c3b0e1eb93d3e55d9664c/pyobjc_framework_findersync-12.2.1.tar.gz", hash = "sha256:be3d41c9b836a53f24473e064ade6bd9ac071cc483377547cb6603e5a0de5d90", size = 14303, upload-time = "2026-06-19T16:20:37.63Z" } wheels = [ @@ -3487,8 +3481,8 @@ name = "pyobjc-framework-fsevents" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/fc/b31d09b6b58e50c8bcd16acf251d397bafc454bff9160f0e2c922cc9cbe4/pyobjc_framework_fsevents-12.2.1.tar.gz", hash = "sha256:f78c98f68bc643794668a9484fc348ef3e98df359db7d8726cada35310af93b4", size = 27163, upload-time = "2026-06-19T16:20:38.372Z" } wheels = [ @@ -3507,8 +3501,8 @@ name = "pyobjc-framework-fskit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/38/4d47e4c2ef4a0e474469a715e5244b8e8dde89edde12c94551c5ed3ff7b0/pyobjc_framework_fskit-12.2.1.tar.gz", hash = "sha256:2607cef80fabe2394b30e1e3c10dc942c709afdbe7baacd3f86112b38add942b", size = 49577, upload-time = "2026-06-19T16:20:39.181Z" } wheels = [ @@ -3527,8 +3521,8 @@ name = "pyobjc-framework-gamecenter" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/5d/a2969d6cb14a9fe10a744a89daa2b671a4fb5dd25354e75511a65f7f8249/pyobjc_framework_gamecenter-12.2.1.tar.gz", hash = "sha256:70fff5b1c0ac9d622709b4deb8e0bdf47cfa43591f1438ce2c6099abd93bbfde", size = 32149, upload-time = "2026-06-19T16:20:40.006Z" } wheels = [ @@ -3547,8 +3541,8 @@ name = "pyobjc-framework-gamecontroller" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/94/3f01f6a15b892402e9ec5dd5530e53ce7feab236c374236059ab10961ee7/pyobjc_framework_gamecontroller-12.2.1.tar.gz", hash = "sha256:d40667869da0ef5d9905b4b3c365e275f731758bc0b09f10f7dfba579b1ee7d0", size = 65320, upload-time = "2026-06-19T16:20:40.792Z" } wheels = [ @@ -3567,9 +3561,9 @@ name = "pyobjc-framework-gamekit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a0/f3/6a4ae01c6a8e0e5b74e818694139edf9ebd395bbe04d275fb5f5e73ac919/pyobjc_framework_gamekit-12.2.1.tar.gz", hash = "sha256:e5e90dfa0eba5215406710d636c08ee9aa4ba886d9e0bf11849247b161aef1da", size = 82427, upload-time = "2026-06-19T16:20:41.641Z" } wheels = [ @@ -3588,9 +3582,9 @@ name = "pyobjc-framework-gameplaykit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-spritekit" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-spritekit", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/fa/df67a7bd14b44808060dcf82da26492cd7365bd6759d9437a004fb761a32/pyobjc_framework_gameplaykit-12.2.1.tar.gz", hash = "sha256:6ef81407e241016853cfdc6e580503d2d75a452ab0028f5c83390f102685c853", size = 50742, upload-time = "2026-06-19T16:20:42.656Z" } wheels = [ @@ -3609,8 +3603,8 @@ name = "pyobjc-framework-gamesave" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/96/c4c604170d28f2a2cddb1217dd0d8340ef9435ab2cf1042d85b45a14c2ed/pyobjc_framework_gamesave-12.2.1.tar.gz", hash = "sha256:d317d37f2716194e61cc91e855d6054c3c2b7ceaa82aaeff9c688d9f2cc43a42", size = 13238, upload-time = "2026-06-19T16:20:43.664Z" } wheels = [ @@ -3622,8 +3616,8 @@ name = "pyobjc-framework-healthkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ed/a6/a7f9f6d525c19ff1b2533220058cc70ca5bf3976a6adbe2efaa66ff3a349/pyobjc_framework_healthkit-12.2.1.tar.gz", hash = "sha256:d0a8c956746e8705edbe76a046e8c17ab6d7ae2603d8436e4477d30573acb457", size = 116224, upload-time = "2026-06-19T16:20:44.519Z" } wheels = [ @@ -3642,8 +3636,8 @@ name = "pyobjc-framework-imagecapturecore" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/57/fb/792b0945e2fb9de91b388e56603f5e6548573511cb40555ca0047d7f798b/pyobjc_framework_imagecapturecore-12.2.1.tar.gz", hash = "sha256:c1568be8cc0fd06046f7e344634951b3c02af3ad4f8a35fe1e2fecd9547b7042", size = 53435, upload-time = "2026-06-19T16:20:45.418Z" } wheels = [ @@ -3662,8 +3656,8 @@ name = "pyobjc-framework-inputmethodkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c3/9c/2bb0c543cfb7a1d38f4750b8b54c57663ec7c54f07499a3218c2a16e3e54/pyobjc_framework_inputmethodkit-12.2.1.tar.gz", hash = "sha256:008d792827ea1b11a051f4871c99c720ca5430395078158f082846cab43b04e1", size = 26255, upload-time = "2026-06-19T16:20:46.333Z" } wheels = [ @@ -3682,8 +3676,8 @@ name = "pyobjc-framework-installerplugins" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/2e/23d4d49b755fc6e179956d745311115dbff6b43a505a8ad627a13a301082/pyobjc_framework_installerplugins-12.2.1.tar.gz", hash = "sha256:118ee84e6e7f6f7913ade58818bfd2c12e2078ff7f6090e4941df1e739c8685d", size = 25978, upload-time = "2026-06-19T16:20:47.151Z" } wheels = [ @@ -3695,9 +3689,9 @@ name = "pyobjc-framework-instantmessage" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/66/89688001f6b1a76a09876b4fbe02760994b783d031e05b3d7e039514748a/pyobjc_framework_instantmessage-12.2.1.tar.gz", hash = "sha256:80d31bca02459c6d2364605b51a8064d0fbe3e7dba085b5b8ac59ee98157710c", size = 34047, upload-time = "2026-06-19T16:20:47.868Z" } wheels = [ @@ -3709,8 +3703,8 @@ name = "pyobjc-framework-intents" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/a9/107e313345eef0a2be05017227073678b65b58f77af14312ff6403999856/pyobjc_framework_intents-12.2.1.tar.gz", hash = "sha256:579a36b1c2dae423ecf8f7fc02fc8a2a3d079366a073903ba323d40adeeabc2a", size = 187763, upload-time = "2026-06-19T16:20:48.645Z" } wheels = [ @@ -3729,8 +3723,8 @@ name = "pyobjc-framework-intentsui" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-intents" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-intents", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/45/31/1426d5ca5dba96d82dc08c6f287361eec9c3d8008295403b015843bf7b51/pyobjc_framework_intentsui-12.2.1.tar.gz", hash = "sha256:ec1a0fa3861911da7e43abfb6f783052644d62f587554e15a06303a648f9f361", size = 20768, upload-time = "2026-06-19T16:20:49.755Z" } wheels = [ @@ -3749,8 +3743,8 @@ name = "pyobjc-framework-iobluetooth" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2d/5c/acb79d6180b7dc243b82c129292fc2c9e1f695793165dd6e89f55a000a49/pyobjc_framework_iobluetooth-12.2.1.tar.gz", hash = "sha256:eb99c27187c68f984dee4e9ac620f5b210f11f821a2063b2fb9161359a1f1754", size = 174846, upload-time = "2026-06-19T16:20:50.714Z" } wheels = [ @@ -3769,8 +3763,8 @@ name = "pyobjc-framework-iobluetoothui" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-iobluetooth" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-iobluetooth", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/79/99c3fce73768d0fbbb9c2fbb9dda092c828c8d15e9e5ba4dd802b719582e/pyobjc_framework_iobluetoothui-12.2.1.tar.gz", hash = "sha256:53bbfa9451c3c1bb55e91f063bb0539509e1e2b11887736967cc218b93695087", size = 18013, upload-time = "2026-06-19T16:20:51.717Z" } wheels = [ @@ -3782,8 +3776,8 @@ name = "pyobjc-framework-iosurface" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/bb/9f1b513ce177d725b6aa0936f69ece74afa695698ff2f59660b427824b4e/pyobjc_framework_iosurface-12.2.1.tar.gz", hash = "sha256:f886630d6f2419fed9f89152b1e738758b735219bd39506bc12c2e1f65456dea", size = 18604, upload-time = "2026-06-19T16:20:52.414Z" } wheels = [ @@ -3795,8 +3789,8 @@ name = "pyobjc-framework-ituneslibrary" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/11/9d/1b18df4c94a4a45cb9fc86edf2656d1932c1f56c31dcf6ac673cd9138808/pyobjc_framework_ituneslibrary-12.2.1.tar.gz", hash = "sha256:be3afc865881c762765101be35f7216616c5eb4c76025f590e36fe1cbd2518fb", size = 26184, upload-time = "2026-06-19T16:20:53.1Z" } wheels = [ @@ -3808,8 +3802,8 @@ name = "pyobjc-framework-kernelmanagement" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/34/21b155304cd14f2b6ed9b732c2925b1c23c6eb1a941676e83d972c14dddd/pyobjc_framework_kernelmanagement-12.2.1.tar.gz", hash = "sha256:49591c0603057d2ea2596b9b414c38fe521f506e1320753bb49ccb2262b97bdc", size = 11961, upload-time = "2026-06-19T16:20:53.903Z" } wheels = [ @@ -3821,8 +3815,8 @@ name = "pyobjc-framework-latentsemanticmapping" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/08/37/35c98e572e98df7763c6d7bc437c7aee7d5a36c030c51841d793d0e89939/pyobjc_framework_latentsemanticmapping-12.2.1.tar.gz", hash = "sha256:96e6b523c7fe7944cee30b723f035f9082500c3bf8ba8237013c8e37b112c493", size = 15906, upload-time = "2026-06-19T16:20:54.725Z" } wheels = [ @@ -3834,8 +3828,8 @@ name = "pyobjc-framework-launchservices" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-coreservices" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coreservices", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6e/8e/26d4adeb32fcde532d6e3afd342ebfe055017f183b2c2f730a28f1b3de84/pyobjc_framework_launchservices-12.2.1.tar.gz", hash = "sha256:1d288543c1c4e53e6e24314987e18904ada821d6bef5437e6bd9e8e6873fe4a6", size = 20834, upload-time = "2026-06-19T16:20:55.548Z" } wheels = [ @@ -3847,8 +3841,8 @@ name = "pyobjc-framework-libdispatch" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/3f/561653aff3f19873457c95c053f0298da517be89fdfc0ec35115ed5b7030/pyobjc_framework_libdispatch-12.2.1.tar.gz", hash = "sha256:0d24eda41c6c258135077f60d410e704bc7b5a67adcb2ca463918896c7363795", size = 40336, upload-time = "2026-06-19T16:20:56.371Z" } wheels = [ @@ -3867,8 +3861,8 @@ name = "pyobjc-framework-libxpc" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/e5/92d47d5387baa85461be9802dccd90f6fe9232a98878caad7ab899d207df/pyobjc_framework_libxpc-12.2.1.tar.gz", hash = "sha256:83b814672715ef1f4f83eaaae49416a77db38579e6f6af2e14cd328a76f5d598", size = 37265, upload-time = "2026-06-19T16:20:57.109Z" } wheels = [ @@ -3887,9 +3881,9 @@ name = "pyobjc-framework-linkpresentation" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d0/21/368316aa17c5a70a29fb5bc23d29e8608509de539ad5402b8e273dcae5f9/pyobjc_framework_linkpresentation-12.2.1.tar.gz", hash = "sha256:96f30800eef18543a4c75fff6b1a7cce7fcd649564784cc523341870908382c9", size = 13978, upload-time = "2026-06-19T16:20:57.903Z" } wheels = [ @@ -3901,9 +3895,9 @@ name = "pyobjc-framework-localauthentication" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-security" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-security", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ea/e8/fcbea8814ab28d00e18e4f6fc84af2fbf58eee916bfe85a30685abef0729/pyobjc_framework_localauthentication-12.2.1.tar.gz", hash = "sha256:05162d6d603fe6a9bf8eba8d5df7da379bc2b8eaf2a405bf0132a71477f5ed1c", size = 33086, upload-time = "2026-06-19T16:20:58.613Z" } wheels = [ @@ -3922,9 +3916,9 @@ name = "pyobjc-framework-localauthenticationembeddedui" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-localauthentication" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-localauthentication", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/be/1cae60d052314b16e2e279cc187b918637e6afa7f3bc7aca6eb2ae6c2256/pyobjc_framework_localauthenticationembeddedui-12.2.1.tar.gz", hash = "sha256:f97666380541a40e593c7ed2214c11da30effcc909a4b13595220050a9888577", size = 14146, upload-time = "2026-06-19T16:20:59.37Z" } wheels = [ @@ -3936,8 +3930,8 @@ name = "pyobjc-framework-mailkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8d/09/49fc5fe2ba2489b2844e2a2f25cf526718f34c53847009fff2e9b1f94fc2/pyobjc_framework_mailkit-12.2.1.tar.gz", hash = "sha256:8a8e84f6828f13c7c67c6f8f299889127df05d25ffe52b6c942d69a329681c75", size = 23888, upload-time = "2026-06-19T16:21:00.167Z" } wheels = [ @@ -3949,10 +3943,10 @@ name = "pyobjc-framework-mapkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-corelocation" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-corelocation", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/29/6f5a817054f8998629ae4c8146674a78850a56477ebbc8e6cad2732dad3c/pyobjc_framework_mapkit-12.2.1.tar.gz", hash = "sha256:b0d34e03e100adb471b91f0915b6ecb2266251886e54a1729ee534ec832ad392", size = 79578, upload-time = "2026-06-19T16:21:01.038Z" } wheels = [ @@ -3971,8 +3965,8 @@ name = "pyobjc-framework-mediaaccessibility" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/46/c07388b3911f10cf84347c0fc7b250792e6bdabbdd9a51083331b8ae58ff/pyobjc_framework_mediaaccessibility-12.2.1.tar.gz", hash = "sha256:6d816a09d874519bea85035db7c62c0566063a5be63e3ab25b2059205576ade8", size = 17250, upload-time = "2026-06-19T16:21:01.963Z" } wheels = [ @@ -3984,10 +3978,10 @@ name = "pyobjc-framework-mediaextension" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-avfoundation" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coremedia" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-avfoundation", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coremedia", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d6/fd/8f2489cf3673a705d806124edb73ea27438919f58af1fa41dd789163838c/pyobjc_framework_mediaextension-12.2.1.tar.gz", hash = "sha256:d31057582878ec2574559ad253fd448de3f11a68e454d14f0665348c82b3dee0", size = 44554, upload-time = "2026-06-19T16:21:02.824Z" } wheels = [ @@ -4006,9 +4000,9 @@ name = "pyobjc-framework-medialibrary" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/01/3b/af0d8cf4bef550b77ecddea3db59bce0ab3ab5e22e258c583e92ba5a630a/pyobjc_framework_medialibrary-12.2.1.tar.gz", hash = "sha256:18fb56e727399f11ea588d2c512b7585147386892d72c65eb9c4b6387abd6643", size = 19052, upload-time = "2026-06-19T16:21:04.063Z" } wheels = [ @@ -4020,8 +4014,8 @@ name = "pyobjc-framework-mediaplayer" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-avfoundation" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-avfoundation", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/eda7f1cbdb98a712239ea1a5deb12821d07055e16c51e8c25167fc801578/pyobjc_framework_mediaplayer-12.2.1.tar.gz", hash = "sha256:6acead24bb8f12e202976142db656c553b4a25ca2348165c35ce02862a93757a", size = 42670, upload-time = "2026-06-19T16:21:04.973Z" } wheels = [ @@ -4033,8 +4027,8 @@ name = "pyobjc-framework-mediatoolbox" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/03/61/4970e65b4efa1aac9493dbf8420fcc5d053433bf21c19eeec6cc7c8f7fee/pyobjc_framework_mediatoolbox-12.2.1.tar.gz", hash = "sha256:f8757deb15870b7543e2880aa4e7bd248fbc92f6a55763a99fda3703b1b1327d", size = 22811, upload-time = "2026-06-19T16:21:05.897Z" } wheels = [ @@ -4053,8 +4047,8 @@ name = "pyobjc-framework-metal" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/15/46/5920d6cb66cbbe298744889b10b3266b1408ad823855f55cdcb967c0d51d/pyobjc_framework_metal-12.2.1.tar.gz", hash = "sha256:cd362194bdb7fd2a9116b8dc1e6b14ce19629136304cdf6b88d105a969fda72c", size = 238139, upload-time = "2026-06-19T16:21:06.897Z" } wheels = [ @@ -4073,8 +4067,8 @@ name = "pyobjc-framework-metalfx" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-metal" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-metal", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/64/e64620b99d5fae9aa933e4d8189889275a89e4918773af64ea30b202aa89/pyobjc_framework_metalfx-12.2.1.tar.gz", hash = "sha256:c146060268f8c2941dba7695bb1511145035a8468b65d88a668b2eeb4ac8ea07", size = 33394, upload-time = "2026-06-19T16:21:07.855Z" } wheels = [ @@ -4093,9 +4087,9 @@ name = "pyobjc-framework-metalkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-metal" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-metal", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/35/9e/18cd38c650176a9e4895f5b461c843e90fd85004a379fac799b710e813e4/pyobjc_framework_metalkit-12.2.1.tar.gz", hash = "sha256:f2f8e02f4ddeb1d49a5b3def09eddcb8a718289b6ed635fa5f1807165969e798", size = 28180, upload-time = "2026-06-19T16:21:08.766Z" } wheels = [ @@ -4114,8 +4108,8 @@ name = "pyobjc-framework-metalperformanceshaders" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-metal" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-metal", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/ff/6938291dd5a71e39f6948037dbb271993d86a3ecd7706e7cc38034feeaea/pyobjc_framework_metalperformanceshaders-12.2.1.tar.gz", hash = "sha256:a4395f8619ad6f1d382aab5cf116e058b18d3646bec6b730c77daa8f692b5de4", size = 190474, upload-time = "2026-06-19T16:21:09.743Z" } wheels = [ @@ -4134,8 +4128,8 @@ name = "pyobjc-framework-metalperformanceshadersgraph" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-metalperformanceshaders" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-metalperformanceshaders", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/11/15e3acf0636f9418384cd3e213296ad9882f546889865913cfaee2339ed6/pyobjc_framework_metalperformanceshadersgraph-12.2.1.tar.gz", hash = "sha256:656e70c86645814ef1d02bac74933eebc5ff6427100bee4a3bbffde921020ab6", size = 60199, upload-time = "2026-06-19T16:21:10.716Z" } wheels = [ @@ -4147,8 +4141,8 @@ name = "pyobjc-framework-metrickit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/5d/1e0662fc1af513a08474ab7b9193012899e6930a490aefd9c2c531862a91/pyobjc_framework_metrickit-12.2.1.tar.gz", hash = "sha256:096878f3e750d12a7018b07ff3468d405ab3ac108f1aa92bc3123fd06934e344", size = 30581, upload-time = "2026-06-19T16:21:11.423Z" } wheels = [ @@ -4167,8 +4161,8 @@ name = "pyobjc-framework-mlcompute" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fd/2d/16412fc8454bf987c64d7eb18ee0548198b35aad03b4d3cad6047fd18545/pyobjc_framework_mlcompute-12.2.1.tar.gz", hash = "sha256:4ee00b70d549619d63961864d9c2dd93b6db18d1c920242ae961517be7074f84", size = 55020, upload-time = "2026-06-19T16:21:12.198Z" } wheels = [ @@ -4180,9 +4174,9 @@ name = "pyobjc-framework-modelio" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/a5/fbd7e593d0bf0f611b91758a855d1cad14b08f81609a588ce354b5677795/pyobjc_framework_modelio-12.2.1.tar.gz", hash = "sha256:d3706f803dc325c38536fb43fbd4174e958a95f92312a684ae152186661dff2b", size = 83795, upload-time = "2026-06-19T16:21:13.136Z" } wheels = [ @@ -4201,8 +4195,8 @@ name = "pyobjc-framework-multipeerconnectivity" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/5a/cf3fad5f570ad3aa43010ef03e948d4ac1f0a531d3b7d822f1c19004f59a/pyobjc_framework_multipeerconnectivity-12.2.1.tar.gz", hash = "sha256:06f9a354ef0ef77c45c98ba9ef92bc48961522d7b3ba322e56b4ee3d4a46413c", size = 26450, upload-time = "2026-06-19T16:21:14.028Z" } wheels = [ @@ -4221,8 +4215,8 @@ name = "pyobjc-framework-naturallanguage" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ba/12/de013ac3cdbdb3cc616dd373399df4eb34b4459f668456d81f7062bd49c4/pyobjc_framework_naturallanguage-12.2.1.tar.gz", hash = "sha256:fa2d9c7040dcbbe4c7bc83ddfb9e3da20edb49824de8c78d3574aec7065c4043", size = 27243, upload-time = "2026-06-19T16:21:15.105Z" } wheels = [ @@ -4234,8 +4228,8 @@ name = "pyobjc-framework-netfs" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/ad/28624d75b8339f70fc51bbac131d160a92b87c41ba5684a904304f8a6a09/pyobjc_framework_netfs-12.2.1.tar.gz", hash = "sha256:312b3a6ebcba6b3a03bdc7412560d8ddb5ac336c37a1205e32c6b58d831191f2", size = 15153, upload-time = "2026-06-19T16:21:15.911Z" } wheels = [ @@ -4247,8 +4241,8 @@ name = "pyobjc-framework-network" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/32/6a69c5ccbaf38557f6a090b565ea12a773a64f3f29e87e625c03fa46c183/pyobjc_framework_network-12.2.1.tar.gz", hash = "sha256:0cbb405f304f25617f138a2556433e22d4f706e558a78201957f9e2ca3c9ae21", size = 62791, upload-time = "2026-06-19T16:21:16.907Z" } wheels = [ @@ -4267,8 +4261,8 @@ name = "pyobjc-framework-networkextension" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/69/38/7fdd6bce0c65c4ec01662e4489950b712034faa5adb59428a13ce9d56b0c/pyobjc_framework_networkextension-12.2.1.tar.gz", hash = "sha256:7858164a3e28dc81d317412123fcd664424da9afae63036e31979668ecd5972d", size = 81345, upload-time = "2026-06-19T16:21:17.804Z" } wheels = [ @@ -4287,8 +4281,8 @@ name = "pyobjc-framework-notificationcenter" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/22/6e5b84c0b0b187525fe655508adba71fb208abb747326f2a9da84dd2d6b3/pyobjc_framework_notificationcenter-12.2.1.tar.gz", hash = "sha256:952d0bfff1653f16f9e79336c8eeb928ed517f0212c914191a925a85523b5af6", size = 22159, upload-time = "2026-06-19T16:21:18.786Z" } wheels = [ @@ -4307,8 +4301,8 @@ name = "pyobjc-framework-opendirectory" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6b/02/df1979038cdc426990b364b2307ef33f5a6d915e70eda4791daf692024e7/pyobjc_framework_opendirectory-12.2.1.tar.gz", hash = "sha256:1b6dc2eea7857f05063f22e746ae66e8a2a135e41c62b7f3ca7c91f8a5ec5de0", size = 69907, upload-time = "2026-06-19T16:21:19.553Z" } wheels = [ @@ -4320,8 +4314,8 @@ name = "pyobjc-framework-osakit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/41/0a010e6e48e5bb248a8c4d6b7f71ecb1cf17c92b194db512b536f60a54a8/pyobjc_framework_osakit-12.2.1.tar.gz", hash = "sha256:6656e6dab5eb2b571cdcd0d68c0084fa2ac5f85f2f06423e132b410c44e87187", size = 18924, upload-time = "2026-06-19T16:21:20.394Z" } wheels = [ @@ -4333,10 +4327,10 @@ name = "pyobjc-framework-oslog" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coremedia" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coremedia", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/3b/8a38104775d9472cc360018ca358325135779037122813f5d4cf0f1ce4f6/pyobjc_framework_oslog-12.2.1.tar.gz", hash = "sha256:423e19e08d3f01f3b0d53f2b4503322c0ef9d116c0a1f91fe36273232cf0ff22", size = 22322, upload-time = "2026-06-19T16:21:21.153Z" } wheels = [ @@ -4355,8 +4349,8 @@ name = "pyobjc-framework-passkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/b9/5762ada91652118bd08b4bd236e4cff00edf5211403ee49cd8563a2330c2/pyobjc_framework_passkit-12.2.1.tar.gz", hash = "sha256:28de8925d89b705b9344e59498d15e5a10935e982b19eed10f0d498c5e670e7b", size = 68251, upload-time = "2026-06-19T16:21:21.983Z" } wheels = [ @@ -4375,8 +4369,8 @@ name = "pyobjc-framework-pencilkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/6a/f8831e5b5bbdc3e999f3bd95c7e297bd8d8641ec3c1fcd153c77616e0e2f/pyobjc_framework_pencilkit-12.2.1.tar.gz", hash = "sha256:2545561beece43d63c745b6bbc4503cc7c55ad0792d4206916c75da26a4dca49", size = 20110, upload-time = "2026-06-19T16:21:22.756Z" } wheels = [ @@ -4388,8 +4382,8 @@ name = "pyobjc-framework-phase" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-avfoundation" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-avfoundation", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/10/61/1ecd9506eee8698ab1156a7d99980d668e22b73cde9638cec98cd3d610f4/pyobjc_framework_phase-12.2.1.tar.gz", hash = "sha256:31392185ec0d3b0c5974c9b71f0c540843b1bdd884819484e627c6c0d592388a", size = 40754, upload-time = "2026-06-19T16:21:23.51Z" } wheels = [ @@ -4401,8 +4395,8 @@ name = "pyobjc-framework-photos" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/cf/af57f3b72daec93cd92b091473cac35f7616aeb2db5e4ca3a25c984aa5d1/pyobjc_framework_photos-12.2.1.tar.gz", hash = "sha256:e405e612c48563609fe32da9aec9286ed8cab10e226dc58ae6910e141fc46f44", size = 58673, upload-time = "2026-06-19T16:21:24.323Z" } wheels = [ @@ -4421,8 +4415,8 @@ name = "pyobjc-framework-photosui" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/03/e0/5e8aa49c3fe59572f9097bcea75844352c33c88fbdfcc518c6eef7657c88/pyobjc_framework_photosui-12.2.1.tar.gz", hash = "sha256:cb37100f2c75640d036a9fecf476a6fb68d1cafb5878c0e3f7c47054a63218a1", size = 33856, upload-time = "2026-06-19T16:21:25.249Z" } wheels = [ @@ -4441,8 +4435,8 @@ name = "pyobjc-framework-preferencepanes" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/f2/788a198c7b8c44a27c2b358c2cd846bbd97bb6d9c8c457cb248c3c0a8958/pyobjc_framework_preferencepanes-12.2.1.tar.gz", hash = "sha256:1b8e839b364b792441201c1112e17cd6d42f493987169da04ec65eda365d2ede", size = 25113, upload-time = "2026-06-19T16:21:26.178Z" } wheels = [ @@ -4454,8 +4448,8 @@ name = "pyobjc-framework-pushkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/dd/d0ad735548407e862717b7ef08a29dd51b3b9c1c1987ce43f76c25d72c34/pyobjc_framework_pushkit-12.2.1.tar.gz", hash = "sha256:12800cf33aadfdda5df3e487d4ce3d8a79a2a0efcebbd5b1e11a1ff8a1a4067c", size = 20464, upload-time = "2026-06-19T16:21:28.183Z" } wheels = [ @@ -4474,8 +4468,8 @@ name = "pyobjc-framework-quartz" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } wheels = [ @@ -4494,9 +4488,9 @@ name = "pyobjc-framework-quicklookthumbnailing" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bf/49/41d06038c50a750d6172b875d45ec8a180650b98c6e0d8cdce43b4120ef1/pyobjc_framework_quicklookthumbnailing-12.2.1.tar.gz", hash = "sha256:1b348b674569b8df40ef6acebbcdc4e7e8b347b0a437c824b35a6d6f91acc398", size = 15765, upload-time = "2026-06-19T16:21:31.579Z" } wheels = [ @@ -4508,8 +4502,8 @@ name = "pyobjc-framework-replaykit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/a0/2d5903651b581cc9c64cd08d67088310807a169e60465c46d016ac53e2dd/pyobjc_framework_replaykit-12.2.1.tar.gz", hash = "sha256:c5a712ff52ab58c58a53a27e47dba2d3823b13f2587a395855e9c4f0510af6a1", size = 27213, upload-time = "2026-06-19T16:21:32.411Z" } wheels = [ @@ -4528,8 +4522,8 @@ name = "pyobjc-framework-safariservices" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/03/8b/638f43f5f7936dc3919fbb7a76a1efb3826941e49df12bbbb4b95342a594/pyobjc_framework_safariservices-12.2.1.tar.gz", hash = "sha256:5da28790b389efa21a33d2d48d3322dc3670077ec9c8af9054824d42153e0298", size = 27306, upload-time = "2026-06-19T16:21:33.237Z" } wheels = [ @@ -4548,9 +4542,9 @@ name = "pyobjc-framework-safetykit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e3/57/ec33de6440eec0c805643dec1115d7a0cc21be8e8e13dfff52e66de6a0e3/pyobjc_framework_safetykit-12.2.1.tar.gz", hash = "sha256:33defe7e4155dff6abf0a2990bb2a918447b9339199ca92c2f3f219d48189ebf", size = 20855, upload-time = "2026-06-19T16:21:34.08Z" } wheels = [ @@ -4569,9 +4563,9 @@ name = "pyobjc-framework-scenekit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/4f/4c614327db5c8a9af6fd8995bacd5f4d3c331c1be66f29722bac3af594cb/pyobjc_framework_scenekit-12.2.1.tar.gz", hash = "sha256:9f5939ecdfa9c13347f6ab61173ba5eb386766cfebd82200fe7173f70aa34083", size = 132003, upload-time = "2026-06-19T16:21:35.115Z" } wheels = [ @@ -4590,9 +4584,9 @@ name = "pyobjc-framework-screencapturekit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coremedia" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coremedia", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/a6/b7e72e32d3334e13eae592cbcd9f3c060c43adf78ddfebd0eb9c6be0ab05/pyobjc_framework_screencapturekit-12.2.1.tar.gz", hash = "sha256:e419cbf9c2f9cbd172d1c6e5bc69a44e0a7d9e45cf43058d48eeda4f785ce860", size = 37840, upload-time = "2026-06-19T16:21:36.099Z" } wheels = [ @@ -4611,8 +4605,8 @@ name = "pyobjc-framework-screensaver" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/88/32/a3826be36a6473c6bed8f3a7cbd879b8feadfd83463cb1ff615aba66e414/pyobjc_framework_screensaver-12.2.1.tar.gz", hash = "sha256:15eba02075a065283e763c8087b9c6fa565907c95e0575a383c1a6ef4c9b1868", size = 22814, upload-time = "2026-06-19T16:21:36.819Z" } wheels = [ @@ -4631,8 +4625,8 @@ name = "pyobjc-framework-screentime" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/41/93/27470ca21f4c596a0692a3e9cfcd45d38c3bab6f0566bbb5af4e3cfb8b98/pyobjc_framework_screentime-12.2.1.tar.gz", hash = "sha256:c97e700995c03183a1a73f2eaaf90f5ed66d68e8c2e40ff7ed2fddbc77ff7b2f", size = 14074, upload-time = "2026-06-19T16:21:37.618Z" } wheels = [ @@ -4644,8 +4638,8 @@ name = "pyobjc-framework-scriptingbridge" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/97/908c6a8a4eb665c952dd8b6c670eb2ad40661c31721ed47470d1329115b3/pyobjc_framework_scriptingbridge-12.2.1.tar.gz", hash = "sha256:779b2238b33b61fb9ab4fc71d080e42ef7e27562506f5ca9d783effc4c769a5e", size = 21226, upload-time = "2026-06-19T16:21:38.347Z" } wheels = [ @@ -4664,8 +4658,8 @@ name = "pyobjc-framework-searchkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-coreservices" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coreservices", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/53/be9909e2c3672242d5a4f8223a5132d39f3ae9e9b82062e214ddc4db828b/pyobjc_framework_searchkit-12.2.1.tar.gz", hash = "sha256:1fe1ceb2db1d8c86f75484dd9f88ac39dd3d2cffba250498ba0e2312435214cf", size = 31141, upload-time = "2026-06-19T16:21:39.275Z" } wheels = [ @@ -4677,8 +4671,8 @@ name = "pyobjc-framework-security" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/b8/4267b802d8dba6de468e7d0765b05cc4e146fa376ed9f55e0b6461016bef/pyobjc_framework_security-12.2.1.tar.gz", hash = "sha256:d7831b1537f4346892e7f2f0e2b09d79bee98919b0767f4061278d0e03028f2d", size = 181065, upload-time = "2026-06-19T16:21:40.151Z" } wheels = [ @@ -4697,9 +4691,9 @@ name = "pyobjc-framework-securityfoundation" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-security" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-security", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8e/9e/c1b6426d9ba602ceda4f5bf438705e05930d46c3ef561a89736e1b9cea51/pyobjc_framework_securityfoundation-12.2.1.tar.gz", hash = "sha256:b10f7c6f2fea27f105e69e0ef455df10e911748a4a414aff74f9dede48dd2cd3", size = 13103, upload-time = "2026-06-19T16:21:41.065Z" } wheels = [ @@ -4711,9 +4705,9 @@ name = "pyobjc-framework-securityinterface" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-security" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-security", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ca/24/5486d26d86abf4edb639de2eb5598b6c5cebe33a1aa19d55575694d72160/pyobjc_framework_securityinterface-12.2.1.tar.gz", hash = "sha256:08e58cc05741e8515f157854831063261a7247497c996a120f90148b8aa78842", size = 27798, upload-time = "2026-06-19T16:21:41.816Z" } wheels = [ @@ -4732,9 +4726,9 @@ name = "pyobjc-framework-securityui" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-security" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-security", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/54/f9/5aed7140a5f22102cbffad47e1f8a6cc231a428b2021a1731925da9a78f1/pyobjc_framework_securityui-12.2.1.tar.gz", hash = "sha256:87b07746fb9ca7634c3c74f89bf6ab90dffbfe0c0ffb89551aefce0e35353a50", size = 12648, upload-time = "2026-06-19T16:21:42.645Z" } wheels = [ @@ -4746,9 +4740,9 @@ name = "pyobjc-framework-sensitivecontentanalysis" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/8e/50648c1e4029611acfa6a5cc6c20e3f36d9754414c7c9c690ef142ee1c6e/pyobjc_framework_sensitivecontentanalysis-12.2.1.tar.gz", hash = "sha256:e958e4333b72e7bd93a32be6c3118c50be8e98de6ca7a41bbf076a712ab2ca21", size = 14428, upload-time = "2026-06-19T16:21:43.381Z" } wheels = [ @@ -4760,8 +4754,8 @@ name = "pyobjc-framework-servicemanagement" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/2b/289270180fc32c2297907e6576355aaabf004297d9830a62f9792a5bc95b/pyobjc_framework_servicemanagement-12.2.1.tar.gz", hash = "sha256:99ceee681fea1e57246d33acbe199100f2e35a09cac97ae1c271e34073c28763", size = 15295, upload-time = "2026-06-19T16:21:44.189Z" } wheels = [ @@ -4773,8 +4767,8 @@ name = "pyobjc-framework-sharedwithyou" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-sharedwithyoucore" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-sharedwithyoucore", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/aa/85/8a2d509a27814d56e064f15469b8fd9720ce5c7ec669bcb0cf4b2e800b25/pyobjc_framework_sharedwithyou-12.2.1.tar.gz", hash = "sha256:b1908b9822244ea31d4d546118389c69687982e5fa67bc72cbf1a9e09f1f84b3", size = 27310, upload-time = "2026-06-19T16:21:45.008Z" } wheels = [ @@ -4793,8 +4787,8 @@ name = "pyobjc-framework-sharedwithyoucore" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/05/5e9ba6cdde040717004115a1254ee315434bf7df5f2ee9f9f9ce619bf6dd/pyobjc_framework_sharedwithyoucore-12.2.1.tar.gz", hash = "sha256:b8a4d2d79702756d9fffc5e17f83b45d52c469579acde873a487842ac334384c", size = 24333, upload-time = "2026-06-19T16:21:45.714Z" } wheels = [ @@ -4813,8 +4807,8 @@ name = "pyobjc-framework-shazamkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/7b/12a46aee28ffc14a5118ab45be8f0f629feece3548df81d934e31e723ada/pyobjc_framework_shazamkit-12.2.1.tar.gz", hash = "sha256:4cfa9325e381e8b365b2d4725b9165a5e52f7986f28dcf23c3e7f0bd3bddf3aa", size = 26062, upload-time = "2026-06-19T16:21:46.513Z" } wheels = [ @@ -4833,8 +4827,8 @@ name = "pyobjc-framework-social" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/23/25/0a3ba41ba7aa0380968854a53f02e549afe0b5af89856abfcc2f8988e050/pyobjc_framework_social-12.2.1.tar.gz", hash = "sha256:c2877c7ddbed8f3ea17065687725df57243d25b64beb3b885e8a18482c24bf7f", size = 13751, upload-time = "2026-06-19T16:21:47.204Z" } wheels = [ @@ -4846,8 +4840,8 @@ name = "pyobjc-framework-soundanalysis" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/43/b6a1644c01010c50dd4a69b0e4ed144139d60d7900edae937486c62af73b/pyobjc_framework_soundanalysis-12.2.1.tar.gz", hash = "sha256:d17bdea63c2b910c2046ba43383b29b82d594a37feee4431d45b55adc08a3882", size = 15777, upload-time = "2026-06-19T16:21:47.973Z" } wheels = [ @@ -4859,8 +4853,8 @@ name = "pyobjc-framework-speech" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/99/fe/0d1f1710d77deb2aefbdcca344b682f86277a0cf2df55f49615bdee213e1/pyobjc_framework_speech-12.2.1.tar.gz", hash = "sha256:77e01c6e92b34e3bb47dc5b0c43a59cb7941a7eb6ce595ca3de3a686a5df21fa", size = 27772, upload-time = "2026-06-19T16:21:48.792Z" } wheels = [ @@ -4879,9 +4873,9 @@ name = "pyobjc-framework-spritekit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9c/0a/3da8666b42a696b1d82a283ba442f2941060fa304359d4855c3c6072dd3d/pyobjc_framework_spritekit-12.2.1.tar.gz", hash = "sha256:989a25cb2e9d45ecb97655f55464e44a342eb525a891e46a563aae27c683eac0", size = 83906, upload-time = "2026-06-19T16:21:49.536Z" } wheels = [ @@ -4900,8 +4894,8 @@ name = "pyobjc-framework-storekit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ff/2e/d299e11aefcc70414281e5f82e2297a314c108c5bf81091149f1c3f6411a/pyobjc_framework_storekit-12.2.1.tar.gz", hash = "sha256:5d3b306f08810c485a4bd184bc6e45cc92eaf4cb6d4b88bf701bcb854ab66f59", size = 40971, upload-time = "2026-06-19T16:21:50.541Z" } wheels = [ @@ -4920,8 +4914,8 @@ name = "pyobjc-framework-symbols" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/1f/6575bd54a71ccae067b56cbe9277b65d095c9a9880c9e059d8d2e845a8ae/pyobjc_framework_symbols-12.2.1.tar.gz", hash = "sha256:c15d32ae7c94e0e95fd83bc2099437a70671b79d0f438a1b0cd1f3eb2cc6f365", size = 14778, upload-time = "2026-06-19T16:21:51.277Z" } wheels = [ @@ -4933,9 +4927,9 @@ name = "pyobjc-framework-syncservices" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coredata" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coredata", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/8d/ad9492c9f0a305e4a1e30968407385cd4bc61051a1a2f9c40677c964fff9/pyobjc_framework_syncservices-12.2.1.tar.gz", hash = "sha256:c351286f14d257e20f8305665825fd73f108c8f0d787e4643818dee5debb3511", size = 34867, upload-time = "2026-06-19T16:21:52.215Z" } wheels = [ @@ -4954,8 +4948,8 @@ name = "pyobjc-framework-systemconfiguration" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/6f/805ee24f58c13eb593e458ec1f79d94d3415edace02eec7c614ef3518f69/pyobjc_framework_systemconfiguration-12.2.1.tar.gz", hash = "sha256:877a90eafe3df72625e50d61fc9c6dbd40e8cdabab7c4101992090107bb71ddb", size = 63314, upload-time = "2026-06-19T16:21:53.115Z" } wheels = [ @@ -4974,8 +4968,8 @@ name = "pyobjc-framework-systemextensions" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2d/1b/6edbfef0f03ff67bc22ba03ac7027aee804ea5b16512dd9547dab5786c81/pyobjc_framework_systemextensions-12.2.1.tar.gz", hash = "sha256:4f9f6d729544acfab49fe02a4b38712112c51f07790f8d4bf7ac3bb18c334839", size = 21667, upload-time = "2026-06-19T16:21:53.916Z" } wheels = [ @@ -4994,8 +4988,8 @@ name = "pyobjc-framework-threadnetwork" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c6/ae/a2f44eade30d2231938acb81ef049f4172afd6e9acbdff55eca036e75038/pyobjc_framework_threadnetwork-12.2.1.tar.gz", hash = "sha256:98397cf45354750c4b5a1237f6961c616d12f3ad0a570b3808a03e5d3373f64a", size = 13341, upload-time = "2026-06-19T16:21:54.938Z" } wheels = [ @@ -5007,8 +5001,8 @@ name = "pyobjc-framework-uniformtypeidentifiers" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/a1/108fa1e5a3dd8aff626f98fb97de370323b290404b04ffa2ef9420665ed3/pyobjc_framework_uniformtypeidentifiers-12.2.1.tar.gz", hash = "sha256:1fb89d13aa3c2df8e6d6536f6df3493fe5a6caefd2a5adebf17c5af3b29ed4a2", size = 20679, upload-time = "2026-06-19T16:21:55.739Z" } wheels = [ @@ -5020,8 +5014,8 @@ name = "pyobjc-framework-usernotifications" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/77/bdd49a1fe4d89ce86078e94121cf6e9c7e2f733215556194da04c512075e/pyobjc_framework_usernotifications-12.2.1.tar.gz", hash = "sha256:64379ab6b603949ea20b7852343cbcff7403443b4876ec8ac0c03bd0f11b1b22", size = 33955, upload-time = "2026-06-19T16:21:56.492Z" } wheels = [ @@ -5040,9 +5034,9 @@ name = "pyobjc-framework-usernotificationsui" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-usernotifications" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-usernotifications", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/9a/566813aed69566c68045fc080b2238f62df131826d16da42529e89d56434/pyobjc_framework_usernotificationsui-12.2.1.tar.gz", hash = "sha256:ea6aecea828e088416aa6d14055c023cac6aa03ea0cdded8baba679ce5414cc8", size = 13457, upload-time = "2026-06-19T16:21:57.294Z" } wheels = [ @@ -5054,8 +5048,8 @@ name = "pyobjc-framework-videosubscriberaccount" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/84/4a/b3485f9e123b05a44852f07ca9387d8ad719a3ecbe0a10e2d2f726a460ff/pyobjc_framework_videosubscriberaccount-12.2.1.tar.gz", hash = "sha256:7af53b410d3943be09d8601bd8d50fd0e193e4e5577fdaa2f801d7f9dbc0454d", size = 21346, upload-time = "2026-06-19T16:21:58.123Z" } wheels = [ @@ -5067,10 +5061,10 @@ name = "pyobjc-framework-videotoolbox" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coremedia" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coremedia", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/82/307369b27b00b38cf6b6e021fcdce0ae6f1a91f24fed9b090addbe90f1e2/pyobjc_framework_videotoolbox-12.2.1.tar.gz", hash = "sha256:83582abc25e55ed04f0267fa69923839d779a11da3d34ee8c93ad1a66439e48d", size = 64995, upload-time = "2026-06-19T16:21:59.115Z" } wheels = [ @@ -5089,8 +5083,8 @@ name = "pyobjc-framework-virtualization" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/49/5c306fac7b85c4f875b01f38266b75b9b8ba80a9747bfcc692c9b83adffb/pyobjc_framework_virtualization-12.2.1.tar.gz", hash = "sha256:dd752180219ddc54112876576debca9f3316e91ce75afce622981eeb8c9a0f4a", size = 49190, upload-time = "2026-06-19T16:22:00.154Z" } wheels = [ @@ -5109,10 +5103,10 @@ name = "pyobjc-framework-vision" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coreml" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-coreml", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/7a/1fdffff1b6bf124b260a2169869f4b71a08b9f6603698f7dec990d5ae5f3/pyobjc_framework_vision-12.2.1.tar.gz", hash = "sha256:debfd59dd7d962a6053bf733370148c11a9ec44091b517a0966f48d81c305879", size = 72683, upload-time = "2026-06-19T16:22:01.102Z" } wheels = [ @@ -5131,8 +5125,8 @@ name = "pyobjc-framework-webkit" version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/11/d2/b230c594f70ecb970b4cef67bae2648d1bfa5b381e9b7e3710bf24ec8887/pyobjc_framework_webkit-12.2.1.tar.gz", hash = "sha256:a56acae55b50d549b20dff2921ad1099add8fbc377d0de09ddc2ba50957f7def", size = 332374, upload-time = "2026-06-19T16:22:01.988Z" } wheels = [ From f2a2ecb4f0f9586c48ab8072c14be25adddf2df0 Mon Sep 17 00:00:00 2001 From: Cheney Zhang Date: Wed, 9 Sep 2026 12:33:10 +0800 Subject: [PATCH 3/6] feat: govern environment-driven evolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 班扬 --- AGENTS.md | 10 + src/leapflow/cli/context.py | 110 +- src/leapflow/config.py | 27 + src/leapflow/daemon/monitor_coordinator.py | 164 +- src/leapflow/dashboard/service.py | 93 +- src/leapflow/dashboard/static/app.js | 16 +- .../dashboard/templates/evolution.yaml | 490 ++++++ src/leapflow/domain/__init__.py | 8 + src/leapflow/domain/capability_requirement.py | 1 + src/leapflow/domain/evolution_intent.py | 258 +++ src/leapflow/domain/evolution_trace.py | 235 +++ src/leapflow/engine/engine.py | 117 +- src/leapflow/engine/session_factory.py | 65 +- src/leapflow/evolution/__init__.py | 25 + src/leapflow/evolution/ledger.py | 509 ++++++ src/leapflow/evolution/observations.py | 252 +++ src/leapflow/evolution/sink.py | 112 ++ src/leapflow/evolution/sweep.py | 266 ++++ src/leapflow/layout.py | 8 + .../learning/capability_effect_verifier.py | 316 ++++ .../learning/capability_gap_detector.py | 169 +- .../learning/capability_observation.py | 55 +- .../learning/outcome_governance_feed.py | 189 +++ src/leapflow/learning/plugin_generator.py | 9 +- src/leapflow/learning/plugin_stats.py | 28 + src/leapflow/learning/plugin_trust.py | 12 + src/leapflow/learning/world_model_driver.py | 266 ++++ src/leapflow/monitor/__init__.py | 2 + src/leapflow/monitor/evolution_producer.py | 1397 +++++++++++++++++ src/leapflow/plugins/adaptive_loop.py | 21 +- src/leapflow/plugins/adaptive_policy.py | 6 +- src/leapflow/plugins/capability_resolver.py | 40 + src/leapflow/plugins/evolution_contracts.py | 91 ++ src/leapflow/plugins/lifecycle_governor.py | 12 +- src/leapflow/plugins/registry.py | 55 +- .../plugins/tool_plugins/self_management.py | 162 ++ .../storage/capability_observation_store.py | 21 + src/leapflow/storage/evolution_trace_store.py | 120 ++ src/leapflow/telemetry/__init__.py | 8 + src/leapflow/telemetry/evolution_tap.py | 96 ++ src/leapflow/tools/config_tools.py | 13 +- src/leapflow/world_model/__init__.py | 3 +- src/leapflow/world_model/trajectory_grader.py | 197 ++- tests/test_architecture_contracts.py | 6 +- tests/test_coevolution_observations.py | 350 +++++ tests/test_coevolution_sweep_wiring.py | 345 ++++ tests/test_concurrent_workspace_governance.py | 314 ++++ tests/test_config_capability_tools.py | 14 +- tests/test_dashboard_i18n_static.py | 12 +- tests/test_dashboard_view.py | 148 ++ tests/test_effect_declaration.py | 237 +++ tests/test_evolution_governance_reachable.py | 283 ++++ tests/test_evolution_ledger.py | 509 ++++++ tests/test_evolution_producer.py | 878 +++++++++++ tests/test_evolution_tap.py | 570 +++++++ tests/test_evolution_verify_and_govern.py | 310 ++++ tests/test_observation_lifecycle.py | 154 ++ tests/test_teacher_capability_validation.py | 146 ++ tests/test_world_model_driven_evolution_p1.py | 316 ++++ tests/test_world_model_driver.py | 241 +++ tests/test_world_model_evolution_p0.py | 307 ++++ 61 files changed, 11116 insertions(+), 78 deletions(-) create mode 100644 src/leapflow/dashboard/templates/evolution.yaml create mode 100644 src/leapflow/domain/evolution_intent.py create mode 100644 src/leapflow/domain/evolution_trace.py create mode 100644 src/leapflow/evolution/__init__.py create mode 100644 src/leapflow/evolution/ledger.py create mode 100644 src/leapflow/evolution/observations.py create mode 100644 src/leapflow/evolution/sink.py create mode 100644 src/leapflow/evolution/sweep.py create mode 100644 src/leapflow/learning/capability_effect_verifier.py create mode 100644 src/leapflow/learning/outcome_governance_feed.py create mode 100644 src/leapflow/learning/world_model_driver.py create mode 100644 src/leapflow/monitor/evolution_producer.py create mode 100644 src/leapflow/plugins/evolution_contracts.py create mode 100644 src/leapflow/storage/evolution_trace_store.py create mode 100644 src/leapflow/telemetry/__init__.py create mode 100644 src/leapflow/telemetry/evolution_tap.py create mode 100644 tests/test_coevolution_observations.py create mode 100644 tests/test_coevolution_sweep_wiring.py create mode 100644 tests/test_concurrent_workspace_governance.py create mode 100644 tests/test_effect_declaration.py create mode 100644 tests/test_evolution_governance_reachable.py create mode 100644 tests/test_evolution_ledger.py create mode 100644 tests/test_evolution_producer.py create mode 100644 tests/test_evolution_tap.py create mode 100644 tests/test_evolution_verify_and_govern.py create mode 100644 tests/test_observation_lifecycle.py create mode 100644 tests/test_teacher_capability_validation.py create mode 100644 tests/test_world_model_driven_evolution_p1.py create mode 100644 tests/test_world_model_driver.py create mode 100644 tests/test_world_model_evolution_p0.py diff --git a/AGENTS.md b/AGENTS.md index acd6be64..3b62cf81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,8 @@ This document is the LeapFlow engineering collaboration contract. It is not only 7. **User-Centric Reliability** — User experience is part of correctness. Every change must keep common paths easy, predictable, recoverable, and must not degrade adjacent workflows. +8. **Environment–Harness Co-evolution** — An environmental change is an observable hypothesis, never authority to mutate the framework. Only a typed, task-relevant delta that survives evidence validation and live capability resolution may produce a governed capability requirement; policy, approval, trust, and lifecycle control every subsequent plugin addition, change, disablement, or removal. The complete causal record — including rejected and no-op branches — must remain available so evolution is explainable, reproducible, and reversible. + ## Code Quality Requirements - SOLID principles are non-negotiable; implementations must be cohesive, well-factored, and easy to reason about @@ -40,6 +42,10 @@ This document is the LeapFlow engineering collaboration contract. It is not only - **TUI Prompt Ownership**: Input prompt and placeholder rendering must have a single owner. Avoid duplicate prompt sources; placeholder text stays visually subordinate, offset after the prompt, and disappears as soon as the user types. - **leapd Runtime Consistency**: Daemon-backed behavior must preserve lifecycle correctness: start, stop, restart, status, RPC streaming, cancellation, pending approvals, runtime config reload, multi-client state, and version consistency. - **Progressive Context Disclosure (PCD)**: Keep one unified execution loop, but never default every turn to full disclosure. Each LLM call must use the smallest sufficient PromptAssemblyPlan for tools, memory, history, reasoning, streaming, and risk; upgrade progressively only when observable signals require it. +- **Task Environment Is a First-Class Signal**: Environment adaptation identifies task environments through declared descriptors and typed structural or affordance deltas. Host capabilities and application affordances are separate concepts; an opaque "changed" hash or a host fingerprint alone cannot establish task compatibility, loss, or a need to evolve. +- **Resolution Before Acquisition**: A classified environment signal carries redacted provenance and becomes a capability requirement only when it is relevant to the active task. Resolve that requirement against the live catalog before opening a proposal: a satisfiable requirement is recorded as a no-op, while an unmet task-critical requirement follows the typed capability-unavailable/recovery path. No environment change may create a duplicate capability merely because it was observed. +- **Experiment Control Plane Is Not the Subject**: Environment sources, fault injection, and harness adapters are discoverable plugins that drive only real production seams. They must not register tools directly, hand-write observation or proposal records, impersonate approval, or introduce a parallel mutation path. Synthetic signals are explicitly enabled only in an experiment profile; production defaults remain unchanged until an operator opts in through the normal configuration surface. +- **Evolution Causality Is Durable and Queryable**: For each observed environmental delta, preserve a redacted causal chain linking source and before/after descriptors, evidence and requirement identifiers, live-resolution result, policy and approval decisions, affected plugin/version/fiber, validation and trust outcomes, and any rollback or retirement. A rejected gate, duplicate resolution, failed validation, or no-op is a first-class result, not missing telemetry; it proves why the framework did not change. - **Gateway as Signal Boundary**: External IM/platform integrations are not just messaging features; they extend LeapFlow's Observe/Orient boundary into collaboration environments. Inbound platform events must enter as structured signals (`BackendEvent` → normalized domain event/message), pass SNR filtering and privacy/safety gates, then feed memory, decision, and action paths according to their classification. - **Transport-Lifecycle Separation**: Short-lived actions (`ExecutionBackend`/`CliBackend`) and long-lived observations (`BackendEventSource`) are separate responsibilities. Do not implement streaming subscribers, webhooks, polling loops, or CLI NDJSON consumers inside one-shot action execution code. - **Platform-Neutral Gateway Core**: Gateway core owns protocols, lifecycle, routing, session isolation, approval, audit, and memory integration. Platform adapters own authentication, send semantics, event-source configuration, and schema normalization. Core modules must not import platform SDKs directly. Per-vendor code — including credential validators — lives in a platform sub-package (`adapters/`, `normalizers/`, `action_packs/`, `validators/.py`), never in a core module; core keeps only the neutral registry and contracts. @@ -80,6 +86,7 @@ The plugin subsystem is not a feature area — it is how the product is composed - **Plugin governance is cold-path (MANDATORY)**: fiber state, trust ledgers, usage statistics, health producers, advisors, proposal queues, and marketplace work must add no per-turn cost to the hot path. Trust is flushed to DuckDB only on level transitions (plus a final `atexit` flush), and usage samples stay in bounded deques. A governance feature that measurably slows an ordinary turn is a defect in the feature, not a cost to accept. - **Plugin mutation is uniformly HIGH risk and never permanently granted**: any action whose `metadata.platform == "plugin_management"` is forced to `RiskLevel.HIGH` with `allow_permanent=False` in `security/risk.py` — defense-in-depth that holds even when caller metadata is wrong. Install, reload, rollback, enable, disable, and remove each build an `ActionDescriptor` and go through `ApprovalOrchestrator` per invocation. The single exemption is `plugin_reload` at `PRODUCTION` trust, which is earned evidence rather than a configured bypass. With no gate installed (in-process CLI binds none), every mutation is denied: code that can rewrite the agent's own composition must never be installable through an unguarded path. - **Self-evolution is a governed pipeline, not a code-writing shortcut**: capability gap → proposal → generate → validate (syntax → structure → import/Protocol conformance) → compatibility assessment → approval → write → sandbox smoke → register at DRAFT → behavior tests → probation → trust accrual → verify, with quarantine and rollback as the failure path. An `INCOMPATIBLE` verdict is rejected before any file write; a failure at any later stage rolls back the fiber, the `sys.modules` entry, and the written file. Each next action comes from `AdaptiveEvolutionPolicy` reading structured requirement, risk, trust, and status — never from natural-language intent — and the autonomy level is configuration, so raising it is a deliberate operator decision rather than a code path. +- **Co-evolution makes every capability transition visible, including retirement**: an environment-driven install, reload, disable, rollback, or remove records its causal requirement/evidence, pre- and post-mutation catalog state, artifact identity or digest, compatibility and behavior verdicts, approval, and resulting fiber/trust state through the existing audit, proposal, version, and lifecycle stores. An unselected or superseded proposal must be resolved, expired, or retained with an explicit reason; no proposal, plugin artifact, or registered effect may become an untracked permanent residue. - **Untrusted code is isolated before it is trusted**: `requires_sandbox` defaults to `True`; sandboxed plugins run in a subprocess over JSON-RPC with a bounded invoke timeout and receive no host-side runtime dependencies. Marketplace artifacts are verified by SHA-256 checksum and, when trusted pubkeys are configured, by Ed25519 signature over the canonical `name|version|entry_point|checksum_sha256` payload. Validation re-runs on the install path even for marketplace code that was already checked. - **Plugins are process-global; sessions are not**: the registry is a daemon-wide singleton, so install, reload, disable, and remove change the capability set for every connected client at its next turn, and trust accrues from all of them. Any change to plugin state must be assessed against the concurrent-TUI contract — per-turn snapshots are the only isolation, and there is deliberately no per-workspace plugin set. - **Self-capability answers come from the live registry, never from documentation**: when LeapFlow reports what it supports — plugins, self-evolution, hot reload, version management — the evidence is `plugin_list`'s live `capability_report` or an equivalent runtime registry read. If runtime introspection fails, state that the running state could not be verified; never infer a capability from README, design docs, or memory. @@ -191,6 +198,7 @@ Each journey also declares two cost ceilings, both enforced at the proxy and rep - **Mock at boundaries only**: mock external I/O (network, disk), never internal logic - **A test may not fabricate the wiring it claims to cover**: building an object with `object.__new__` and assigning the private attributes the code reads cannot detect a wrong attribute *name* — the test simply agrees with the typo. Calibration tests did exactly that and stayed green while every real turn raised `AttributeError`. Any test whose stated purpose is wiring must construct the real object and drive the production path. - **Multi-client behavior needs multi-client tests**: session routing, `status()`, stream metadata, and client-lease changes require two sessions in two workspaces asserting that neither sees the other's identity, usage, or turn state. Single-session tests cannot observe cross-client leakage, which is why a leak shipped with a green suite. +- **Co-evolution experiments need longitudinal counterfactual evidence**: exercise an unchanged baseline, an irrelevant delta that is correctly rejected, a task-relevant delta already satisfied by the live catalog, and an unmet requirement that traverses the real governed lifecycle. Use isolated profiles and real integration seams; deterministic reruns are not independent environmental units. Freeze the experiment configuration and corpus, preserve an immutable evidence bundle with a digest and known threats, and report the evidence level, unit count, no-op/rejection outcomes, mutations, and final steady state. - **Change-scoped validation**: Run the most specific relevant tests first, then broaden only as needed: CLI/TUI changes require CLI/TUI tests; leapd changes require daemon RPC/lifecycle tests; storage or memory changes require persistence tests; gateway, IM, event-source, or approval changes require connector lifecycle, event normalization, routing, idempotency, self-message filtering, security/approval, and failure-recovery tests; plugin contract, registry, lifecycle, sandbox, marketplace, or trust changes require the plugin reload, scoped-registry, fiber/effect-scope, sandbox, marketplace-signing, trust-learning, and architecture-contract tests; skills, learning, perception, and copilot changes require their lifecycle or pipeline tests. - **Recovery strategy isolation**: Each `RecoveryStrategy` must be testable in isolation — verify `can_apply` predicates, `decide` outputs, and side-effect-state gating independently of the coordinator and other strategies. - **Budget boundary tests**: Verify that recovery budgets exhaust correctly (per-category, per-turn, deadline), that exhaustion produces a deterministic halt decision, and that cost accounting is exact. @@ -224,6 +232,8 @@ Each journey also declares two cost ceilings, both enforced at the proxy and rep - Registering a process-global interceptor, subscription, or background task without a matching cleanup effect on the plugin's `EffectScope` - Reloading a plugin by injecting into `sys.path` instead of a file-backed import spec, or overwriting a live handler to claim a tool name another plugin owns - Adding per-turn cost for plugin governance (trust, stats, health, advisor, proposals) — governance is cold-path +- Treating an environment delta, a model-authored evolution hypothesis, or a successful experiment run as authorization to mutate the framework; each must still pass evidence validation, live resolution, policy, approval, sandboxing, lifecycle, and trust gates +- Using an experiment harness to register plugins directly, forge observations/proposals, bypass approval, or write synthetic evidence into production state without an explicit experiment-profile boundary - Answering a question about LeapFlow's own capabilities from documentation or memory instead of a live registry read - Bare `except:` clauses — always specify the exception type - `# TODO: implement` stubs — implement or don't commit diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index d4f967c4..06a7a821 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -3079,6 +3079,89 @@ async def _persist_session_summary(self) -> None: except Exception: logger.debug("session summary persistence failed", exc_info=True) + async def _run_coevolution_sweep(self): + """Cold-path governance sweep: verify effects, drain quarantine, find residue. + + Runs at the session-end learning boundary, never inside a turn, because + every step here writes to a store or awaits the lifecycle actor and plugin + governance must add no per-turn cost. + + Called unconditionally: with nothing to verify and no quarantine candidate + the sweep emits its no-op traces and returns an empty outcome. That is the + point -- a reader (and the evolution dashboard) must be able to tell a quiet + sweep from a sweep that never ran. + + Returns the ``SweepOutcome``, or ``None`` if the sweep could not be built. + """ + try: + from leapflow.evolution.observations import ( + current_observations, + current_quarantine_tracker, + ) + from leapflow.evolution.sweep import CoevolutionSweep + + # The process tracker, not a private one: the tool-outcome sink increments + # that instance, so a sweep with its own would drain something nobody fed. + sweep = CoevolutionSweep( + governor=getattr(self, "lifecycle_governor", None), + tracker=getattr(self, "_quarantine_tracker", None) + or current_quarantine_tracker(), + ) + # Facts are collected where they are produced -- the engine's resolution + # path, the install tools, and the tool-outcome sink -- so the sweep reads + # the process buffer rather than the CLI reaching into other layers. + observations = current_observations() + return await sweep.run( + verifications=observations.drain_verifications(), + acquired_plugin_ids=observations.acquired_plugin_ids(), + resolutions=observations.resolutions(), + ) + except (ImportError, AttributeError, OSError, RuntimeError, TypeError, ValueError): + logger.debug("co-evolution sweep unavailable", exc_info=True) + return None + + async def _drive_world_model_evolution(self, trajectory: list, goal: str): + """Let the world model propose capability gaps from episode hindsight. + + Returns a ``WorldModelDriveResult`` (whose ``grades`` the caller reuses so + no second LLM call is made), or ``None`` when the driver cannot be + assembled -- in which case the caller falls back to plain grading. + + Runs only at the session-end learning boundary, so it adds no per-turn + cost. Intents are written as ordinary structured evidence and are admitted + only if ``accepted_evidence_kinds`` includes ``world_model_intent``; the + driver never writes around that gate. + """ + try: + from leapflow.learning.capability_observation import ( + CapabilityEvidenceClassifier, + CapabilityObservationService, + ) + from leapflow.learning.world_model_driver import WorldModelEvolutionDriver + from leapflow.storage.capability_observation_store import ( + JsonCapabilityObservationStore, + ) + + settings = self.settings + profile_layout = getattr(settings, "profile_layout", None) + if profile_layout is None or self.trajectory_grader is None: + return None + service = CapabilityObservationService( + JsonCapabilityObservationStore(profile_layout.capability_observations_path), + classifier=CapabilityEvidenceClassifier.from_settings(settings), + ) + driver = WorldModelEvolutionDriver( + teacher=self.trajectory_grader, intake=service + ) + return await driver.drive( + trajectory, + goal, + workspace_root=str(getattr(settings, "workspace_root", "") or ""), + ) + except (ImportError, AttributeError, OSError, RuntimeError, TypeError, ValueError): + logger.debug("world-model evolution driver unavailable", exc_info=True) + return None + async def _on_session_end_learning(self) -> None: """End-of-session OPD learning pipeline (8 phases) with full observability. @@ -3110,14 +3193,31 @@ async def _on_session_end_learning(self) -> None: try: trajectory, goal = self.prediction_loop.flush_trajectory() if trajectory: - grades = await self.trajectory_grader.grade_trajectory( - trajectory, goal=goal, - ) + # The world model is the first driver of capability evolution: + # the same hindsight call that grades the episode also proposes + # any capability it found missing, and those proposals enter the + # ordinary governed evidence path. Admission is still gated by + # ``accepted_evidence_kinds``, so this is inert until opted in. + drive = await self._drive_world_model_evolution(trajectory, goal) + grades = list(drive.grades) if drive is not None else None + if grades is None: + grades = await self.trajectory_grader.grade_trajectory( + trajectory, goal=goal, + ) if grades and self.replay_engine is not None: self.replay_engine.set_replay_priorities(grades) + phase_detail = {"actions_graded": len(grades) if grades else 0} + if drive is not None: + phase_detail.update(drive.to_dict()) + # Cold-path governance sweep: effect verification, quarantine + # drain, reclamation. Runs whether or not the teacher proposed + # anything, so its no-op traces distinguish a quiet session from + # a sweep that never ran. + sweep = await self._run_coevolution_sweep() + if sweep is not None: + phase_detail.update(sweep.to_dict()) observer.on_phase_success( - "trajectory_grading", time.perf_counter() - t0, - {"actions_graded": len(grades) if grades else 0}, + "trajectory_grading", time.perf_counter() - t0, phase_detail, ) phases_ok += 1 else: diff --git a/src/leapflow/config.py b/src/leapflow/config.py index bb937aa0..b1d58c33 100644 --- a/src/leapflow/config.py +++ b/src/leapflow/config.py @@ -357,6 +357,21 @@ class Settings: replay_budget: int = 3 grading_budget: int = 5 distillation_budget: int = 2 + # Evidence kinds the capability-observation layer accepts. Empty tuple keeps + # the shipped behaviour (``unknown_tool`` only). Adding + # ``"world_model_intent"`` lets the world-model teacher drive capability + # evolution; adding structural kinds (``"interface_drift"``, + # ``"affordance_removed"``) lets an environment probe do so. Every admitted + # kind still traverses the unchanged deterministic chain -- resolution, risk, + # approval, validation, trust -- so widening this set adds a *trigger*, never + # a permission. + accepted_evidence_kinds: tuple[str, ...] = () + # Requirement origins permitted to drive an *acquisition*. Empty means + # unrestricted (shipped behaviour): any origin may. Setting it to + # ``("world_model",)`` is the executable form of "all self-evolution's first + # driver is the world model" -- other origins keep being recorded and resolved, + # but can no longer authorise acquiring new code. + evolution_authorising_origins: tuple[str, ...] = () replay_on_session_end: bool = True prediction_structural_blend: float = 0.4 prediction_semantic_blend: float = 0.6 @@ -916,6 +931,16 @@ def _build_settings_from_env( replay_budget = int(os.getenv("LEAPFLOW_REPLAY_BUDGET", "3")) grading_budget = int(os.getenv("LEAPFLOW_GRADING_BUDGET", "5")) distillation_budget = int(os.getenv("LEAPFLOW_DISTILLATION_BUDGET", "2")) + accepted_evidence_kinds = tuple( + kind.strip() + for kind in os.getenv("LEAPFLOW_ACCEPTED_EVIDENCE_KINDS", "").split(",") + if kind.strip() + ) + evolution_authorising_origins = tuple( + origin.strip() + for origin in os.getenv("LEAPFLOW_EVOLUTION_AUTHORISING_ORIGINS", "").split(",") + if origin.strip() + ) replay_on_session_end = _bool("LEAPFLOW_REPLAY_ON_SESSION_END", "true") prediction_structural_blend = float(os.getenv("LEAPFLOW_PREDICTION_STRUCTURAL_BLEND", "0.4")) prediction_semantic_blend = float(os.getenv("LEAPFLOW_PREDICTION_SEMANTIC_BLEND", "0.6")) @@ -1337,6 +1362,8 @@ def _tuple_env(key: str, default: tuple) -> tuple: replay_budget=replay_budget, grading_budget=grading_budget, distillation_budget=distillation_budget, + accepted_evidence_kinds=accepted_evidence_kinds, + evolution_authorising_origins=evolution_authorising_origins, replay_on_session_end=replay_on_session_end, prediction_structural_blend=prediction_structural_blend, prediction_semantic_blend=prediction_semantic_blend, diff --git a/src/leapflow/daemon/monitor_coordinator.py b/src/leapflow/daemon/monitor_coordinator.py index cadef6af..9f78484a 100644 --- a/src/leapflow/daemon/monitor_coordinator.py +++ b/src/leapflow/daemon/monitor_coordinator.py @@ -38,6 +38,7 @@ class MonitorCoordinator: def __init__(self) -> None: self._monitors: Any | None = None + self._evolution_sink: Any | None = None self._bridge_subscribed: bool = False self._bridge_callback: Any | None = None self._event_bus: Any | None = None @@ -59,6 +60,7 @@ async def start(self, ctx: Any, notification_bus: Any, settings: Any) -> None: try: from leapflow.monitor import ( CapabilityAdaptationProducer, + EvolutionProducer, MonitorManager, PluginHealthProducer, SessionAnalysisProducer, @@ -77,7 +79,9 @@ async def start(self, ctx: Any, notification_bus: Any, settings: Any) -> None: self._monitors.producers.register(SignalObservationProducer()) self._monitors.producers.register(CapabilityAdaptationProducer()) self._monitors.producers.register(PluginHealthProducer()) + self._monitors.producers.register(EvolutionProducer()) self._register_hardware_producer(ctx, settings) + self._install_evolution_sink(ctx, settings) setattr(ctx, "monitors", self._monitors) await self._monitors.start() @@ -117,6 +121,100 @@ async def start(self, ctx: Any, notification_bus: Any, settings: Any) -> None: self._monitors = None setattr(ctx, "monitors", None) + def _install_evolution_sink(self, ctx: Any, settings: Any) -> None: + """Turn the evolution probes from no-ops into a durable trace stream. + + Only the daemon installs a sink. An in-process CLI leaves the probes inert, + which is deliberate: traces describe how the framework changed over time, and + a short-lived process has no time in which to change. + + Failure here is silent and total -- no sink means every probe stays a no-op, + which is exactly the state the system runs in by default. The alternative, + failing daemon startup because a transparency panel could not be wired, would + trade a working runtime for an observation of it. + """ + try: + from leapflow.evolution import LedgerEvolutionSink + from leapflow.storage.evolution_trace_store import JsonEvolutionTraceStore + from leapflow.telemetry.evolution_tap import install_sink + + layout = getattr(settings, "profile_layout", None) + path = getattr(layout, "evolution_traces_path", None) + if path is None: + return + sink = LedgerEvolutionSink( + store=JsonEvolutionTraceStore(path), + publish=self._make_evolution_publisher(ctx), + ) + sink.register_atexit() + install_sink(sink) + self._evolution_sink = sink + logger.debug("daemon: evolution trace sink installed at %s", path) + except Exception: # noqa: BLE001 - observability is never a startup dependency + logger.debug("daemon: evolution trace sink not installed", exc_info=True) + + def _make_evolution_publisher(self, ctx: Any) -> Any: + """Build the callback that turns a trace into an ``evolution.*`` event. + + Two constraints shape this. First, probe sites are synchronous and sit deep + inside the registry and the trust ledger, while ``EventBus.handle_event`` is a + coroutine -- so the loop is captured here and the coroutine is *scheduled*, + never awaited. ``call_soon_threadsafe`` is correct from the loop thread and + from any other, which matters because a mutation can arrive from either. + + Second, only runtime-phase traces are published. Boot composition emits one + trace per plugin on every daemon start; publishing those would fire the watch + a dozen times to report that nothing had evolved. The registry marks the phase + itself, so this filters on a declared fact rather than guessing from the kind. + """ + import asyncio + + bus = getattr(ctx, "event_bus", None) + if bus is None or not hasattr(bus, "handle_event"): + return None + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return None + + def _publish(trace: Any) -> None: + detail = dict(getattr(trace, "detail", None) or {}) + if detail.get("phase") == "composition": + return + payload = { + "stage": getattr(getattr(trace, "stage", None), "value", ""), + "kind": str(getattr(trace, "kind", "")), + "summary": str(getattr(trace, "summary", "")), + "correlation": dict(getattr(trace, "correlation", None) or {}), + } + event_type = f"evolution.{payload['kind'] or 'trace'}" + try: + loop.call_soon_threadsafe( + lambda: asyncio.ensure_future(bus.handle_event(event_type, payload)) + ) + except RuntimeError: + # Loop already closed (shutdown). The trace is still buffered and + # will be flushed by the atexit hook; only the live refresh is lost. + logger.debug("daemon: evolution event not published, loop closed") + + return _publish + + def flush_evolution_traces(self) -> int: + """Persist buffered traces, for shutdown paths that want it explicit. + + Ordinary flushing is done by ``EvolutionProducer`` on the monitor tick -- + it is the only consumer, so having it flush before reading is what keeps the + panel and the file consistent. ``register_atexit`` covers process exit. + """ + sink = self._evolution_sink + if sink is None: + return 0 + try: + return int(sink.flush()) + except Exception: # noqa: BLE001 + logger.debug("daemon: evolution trace flush failed", exc_info=True) + return 0 + def _register_hardware_producer(self, ctx: Any, settings: Any) -> None: """Register the physical-bench domain, but only when hardware is enabled. @@ -218,21 +316,41 @@ def signal_noise_stats(self) -> dict[str, Any]: # ── Default event-driven watches ────────────────────────────────────── # Default watches to arm on daemon startup. Each tuple: - # (name, domain, trigger_expr) + #: name, domain, trigger, and whether the *first* cycle is meaningful at once. + #: + #: That last flag is not a convenience. A producer reporting live state (the + #: plugin registry) says something true the instant it is asked, so waiting a + #: full interval leaves the board blank for no reason. A producer reporting an + #: accumulation (hardware sample windows, health trends) has nothing to say until + #: samples exist, and an immediate first cycle publishes an empty snapshot that + #: then sits there as the newest finding until the next interval elapses -- which + #: is how bringing every watch forward broke the hardware board. _DEFAULT_WATCHES = [ - ("fs-observer", "signal", "event:fs.*"), - ("gateway-observer", "signal", "event:gateway.*"), + ("fs-observer", "signal", "event:fs.*", False), + ("gateway-observer", "signal", "event:gateway.*", False), # Plugin health is polled rather than event-driven: trust degradation and a # rising error rate are both trends, visible only by comparing successive # observations. Without this watch the producer is registered and never # called, which is how it sat unused while its own docstring said otherwise. - ("plugin-health", "plugin_health", "5m"), + ("plugin-health", "plugin_health", "5m", False), # Polled for the same reason: an envelope excursion is caught by the event # detector, but cadence drift, quality decay and unpersisted windows are all # trends that only a comparison between cycles can show. Armed regardless of # ``hardware.enabled`` so the board has a watch to report against; with the # producer unregistered the cycle is a no-op. - ("hardware-bench", "hardware", "2m"), + ("hardware-bench", "hardware", "2m", False), + # Framework self-evolution, armed twice on purpose. The domain answers two + # different questions with two different cadences: a *state* snapshot (what is + # registered, what trust each plugin holds, which pipeline segments show + # evidence) has no event to key off, so it must be polled; a *change* has an + # event, and polling would report it up to ten minutes late. The content + # fingerprint makes the overlap free -- when nothing changed the second + # finding dedups and is skipped, so the pair costs one extra cold-path read. + # + # The polled one runs immediately: it reads the live registry, so its first + # answer is already correct and a ten-minute blank board is pure loss. + ("framework-evolution", "framework_evolution", "10m", True), + ("framework-evolution-live", "framework_evolution", "event:evolution.*", False), ] async def _arm_default_watches(self) -> None: @@ -261,7 +379,7 @@ async def _arm_default_watches(self) -> None: logger.debug("daemon: failed to list watches for default arm", exc_info=True) return - for name, domain, trigger_expr in self._DEFAULT_WATCHES: + for name, domain, trigger_expr, run_at_once in self._DEFAULT_WATCHES: entry = existing.get(name) if entry is not None: view, is_active = entry @@ -278,17 +396,49 @@ async def _arm_default_watches(self) -> None: except Exception: logger.debug("daemon: failed to delete stale watch %s", name, exc_info=True) try: - await monitors.arm_watch( + view = await monitors.arm_watch( WatchSpec( name=name, domain=domain, trigger_expr=trigger_expr, ) ) + self._make_due_now(monitors, view, trigger_expr, run_at_once) logger.debug("daemon: armed default watch %s (%s)", name, trigger_expr) except Exception: logger.debug("daemon: failed to arm default watch %s", name, exc_info=True) + @staticmethod + def _make_due_now(monitors: Any, view: Any, trigger_expr: str, run_at_once: bool) -> None: + """Bring a watch's first cycle forward, when its first cycle is meaningful. + + Arming only schedules; the first cycle would otherwise wait a full interval, + and for a ten-minute watch that leaves the board with no data for ten minutes + after every daemon start -- which reads as a broken page rather than a pending + one. + + Opt-in per watch rather than applied to all of them. A producer that reports + an accumulation has nothing true to say before it has accumulated anything, + and its empty first snapshot would then stand as the newest finding until the + next interval elapsed. Applying this to every interval watch made the hardware + board render a digest with zero sample windows. + + Event triggers are excluded regardless: their ``next_due_at`` is 0 because + there is no predictable next time, and forcing one would make an event-driven + watch fire on boot -- reporting as a change something that only happened to be + observed at startup. + + Setting the due time to *now* rather than to the past matters: the scheduler + fast-forwards any task overdue by more than its grace window, which would skip + exactly the cycle this is trying to bring forward. + """ + if not run_at_once or trigger_expr.startswith("event:"): + return + try: + monitors._task_store.advance_next_due(view.watch_id, time.time()) + except Exception: # noqa: BLE001 - a late first cycle is not a startup failure + logger.debug("daemon: could not bring watch %s forward", trigger_expr, exc_info=True) + async def stop(self) -> None: """Stop the monitor runtime.""" if self._monitors is not None: diff --git a/src/leapflow/dashboard/service.py b/src/leapflow/dashboard/service.py index a547ea72..6f9d6002 100644 --- a/src/leapflow/dashboard/service.py +++ b/src/leapflow/dashboard/service.py @@ -179,6 +179,41 @@ def _actionable_notes(inventory: dict[str, Any]) -> list[dict[str, Any]]: ] +def _empty_state(domain: str, watch: dict[str, Any]) -> dict[str, Any]: + """Describe *why* a domain has no payload, so the page can say so. + + A board with no data used to render its metric row as em dashes and, once every + other section learned to hide itself when empty, collapsed to a lone heading. + That is indistinguishable from a broken page, and it is the first thing a new + profile sees. + + Three states, three different next steps, and they are told apart by the watch + rather than guessed: + + * **unscheduled** -- no watch armed for the domain, so the producer is never + called. Nothing will ever appear; the scheduler is off or arming failed. + * **waiting** -- a watch exists but has not completed a cycle yet. Data is + coming, and the only useful thing to say is when. + * **idle** -- the watch has run and produced nothing, which for this domain is a + legitimate quiet answer rather than a fault. + """ + if not watch: + state = "unscheduled" + elif int(watch.get("run_count") or 0) <= 0: + state = "waiting" + else: + state = "idle" + return { + "state": state, + "domain": domain, + "watch_state": str(watch.get("state") or ""), + "muted": bool(watch.get("muted")), + "run_count": int(watch.get("run_count") or 0), + "next_due_at": float(watch.get("next_due_at") or 0.0), + "last_run_at": float(watch.get("last_run_at") or 0.0), + } + + def _hardware_notice( inventory: dict[str, Any], digest: dict[str, Any] ) -> dict[str, str] | None: @@ -295,6 +330,7 @@ def _short_id(value: Any) -> str: _PAYLOAD_DOMAINS: dict[str, tuple[str, str]] = { # template -> (finding domain, data key the template binds to) "capability": ("capability_adaptation", "capability_plan"), + "evolution": ("framework_evolution", "evolution"), "hardware": ("hardware", "hardware"), } """Templates whose data is a producer's finding payload, not a session lens. @@ -405,7 +441,7 @@ async def _build_device( async def _domain_findings( self, provider: DashboardDataProvider, domain: str, watch: dict[str, Any] ) -> list[dict[str, Any]]: - """Return the newest findings of one domain, fetched scoped to its watch. + """Return the newest findings of one *watch*, or of the domain when unarmed. Scoped by ``watch_id`` rather than fetched across all domains and filtered: findings return newest-first inside a byte-bounded batch, and the hardware @@ -416,6 +452,11 @@ async def _domain_findings( watch has been armed for the domain yet there is no id to scope by, so it falls back to an unscoped read and filters, which is correct because a domain with no watch also has no findings. + + Note the unit: this is per *watch*, not per domain. A domain armed with more + than one watch must be resolved by :meth:`_domain_watch_payload`, which asks + each of them; calling this once with an arbitrary watch was how a board went + blank while its data existed under a sibling watch. """ watch_id = str(watch.get("watch_id") or "") findings = await provider.findings(watch_id=watch_id, limit=_DOMAIN_FINDINGS_LIMIT) @@ -423,6 +464,45 @@ async def _domain_findings( return findings return [f for f in findings if str(f.get("domain")) == domain] + async def _domain_watch_payload( + self, provider: DashboardDataProvider, finding_domain: str + ) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Resolve the newest finding of a domain across *every* watch armed for it. + + A domain can legitimately carry more than one watch: framework evolution is + armed twice, once polled for state that has no event and once event-driven for + change that polling would report minutes late. Picking the first match and + scoping the finding read to it meant that whenever the arbitrary winner was the + event watch -- which has produced nothing until something evolves -- the page + received an empty payload and rendered a column of em dashes. The data existed + the whole time, under the sibling watch. + + So each watch of the domain is asked, and the newest finding across all of them + wins. The returned watch is the one that *produced* that finding, because the + observation metadata (last run, next due, run count) describes how the rendered + data was obtained; taking it from a different watch would report a cadence that + had nothing to do with what is on screen. + """ + watches = [ + w for w in await provider.watches() if str(w.get("domain")) == finding_domain + ] + if not watches: + # No watch armed: one unscoped read, filtered by domain. + findings = await self._domain_findings(provider, finding_domain, {}) + return ({}, findings) + + best_watch: dict[str, Any] = watches[0] + best_findings: list[dict[str, Any]] = [] + best_ts = float("-inf") + for candidate in watches: + findings = await self._domain_findings(provider, finding_domain, candidate) + if not findings: + continue + ts = float(findings[0].get("ts") or 0.0) + if ts > best_ts: + best_ts, best_findings, best_watch = ts, findings, candidate + return (best_watch, best_findings) + async def _build_from_finding_payload( self, template: str, @@ -436,9 +516,7 @@ async def _build_from_finding_payload( snapshot of a subject at one instant, and stitching two together would show a state that never existed. """ - watches = await provider.watches() - watch = next((w for w in watches if str(w.get("domain")) == finding_domain), {}) - domain_findings = await self._domain_findings(provider, finding_domain, watch) + watch, domain_findings = await self._domain_watch_payload(provider, finding_domain) payload = dict(domain_findings[0].get("payload") or {}) if domain_findings else {} data = { "title": template.replace("_", " ").title(), @@ -453,6 +531,13 @@ async def _build_from_finding_payload( "run_count": watch.get("run_count", 0), }, } + if not payload: + # An empty payload is a state the page must be able to explain, not a + # reason to render a column of em dashes. Which state it is matters: a + # domain whose watch has never run is waiting, one armed and running is + # idle with nothing to report, and no watch at all means the producer is + # not being scheduled -- three different next steps. + data["empty"] = _empty_state(finding_domain, watch) if template == "hardware": # The fleet list comes from the live registry rather than the cycle payload. # The digest is capped at eight charted channels and is up to a monitor diff --git a/src/leapflow/dashboard/static/app.js b/src/leapflow/dashboard/static/app.js index 567500d2..c09cb855 100644 --- a/src/leapflow/dashboard/static/app.js +++ b/src/leapflow/dashboard/static/app.js @@ -227,11 +227,11 @@ // extended: five of seven templates shipped untranslated in every language, and // the i18n test only checked signal keys, so nothing failed. Keyed by the English // source string, so an untranslated key still renders readable English. - zh: {"A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "比值低于 1.0 表示采样循环未能维持其声明的节奏。", "Action": "动作", "After": "变更后", "An unverified declaration has its writable channels demoted to read-only.": "未核验的声明,其可写通道会被降级为只读。", "Approval": "审批", "Autonomous governance": "自主治理", "Autonomy": "自主级别", "Before": "变更前", "Calibrated at": "校准时间", "Calibration health": "校准健康度", "Calls (decisions)": "观点(决策)", "Candlestick": "K 线", "Capability": "能力", "Capability adaptation": "能力适配", "Channel": "通道", "Channels": "通道数", "Channels that have never been calibrated or whose calibration has expired are shown first.": "从未校准或校准已过期的通道排在最前。", "Command": "命令", "Commanded versus observed, best tracking first": "命令值与实测值对比,跟随最好者在前", "Concerns (open questions)": "关切(待答问题)", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "统计所有绘制通道。“接近”指处于声明边界的 5% 以内。", "Days since": "距今天数", "Decisions read as calls; action items as the execution checklist.": "决策即观点,行动项即执行清单。", "Declared Hz": "声明频率 (Hz)", "Desk brief": "交易台简报", "Device": "设备", "Dropped samples": "丢弃的样本", "Entities as references, and recommended next prompts to advance the work.": "实体作为参考,并给出推进工作的后续追问。", "Entities in play and the open risks still to resolve.": "涉及的实体,以及尚未解决的敞口风险。", "Envelope, rate, staleness and quality observations · newest first": "包络、速率、失联与质量观测 · 最新在前", "Environment": "环境", "Environment, selected plugin tools, and orchestration order.": "环境、已选插件工具及编排顺序。", "Events paced out": "被配速抑制的事件", "Evidence": "证据", "Executable": "可执行", "Execution checklist": "执行清单", "Extracted from this session's tool/file output (not model-generated).": "数据来自本次会话的工具/文件产物(非模型生成)。", "Failures": "失败次数", "Finance lens": "金融视图", "Follow-ups": "后续事项", "Halt": "可急停", "How often each window sat inside, near, or outside its declared limits": "各窗口处于声明限值内、接近边界或越界的频次", "Inquiry brief": "研究简报", "Insights carded as evidence, capped for fast review.": "洞察以证据卡呈现,数量受限以便快速浏览。", "Instruments & counterparties": "标的与交易对手", "Latest capability decision": "最新能力决策", "Lifecycle timeline": "生命周期时间线", "Line of inquiry": "研究主线", "Location": "位置", "Loop phase": "循环阶段", "Mean of each downsample window. Declared limits are listed per channel below.": "每个降采样窗口的均值。各通道的声明限值见下方。", "Mutation": "变更", "Narrative": "叙事", "Narrative pulse": "叙事脉搏", "Next recal due": "下次校准期限", "Normalized error": "归一化误差", "Normalized error is the residual as a share of the channel's declared span.": "归一化误差是残差占该通道声明量程的比例。", "OHLC extracted from captured session market data.": "OHLC 提取自本次会话捕获的行情数据。", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "观测待办、提案状态、策略决策与生命周期结果。", "Observations": "观测数", "Observed Hz": "实测频率 (Hz)", "Observed rate against declared rate": "实测速率与声明速率对比", "Open": "已连接", "Open risks": "敞口风险", "Open/high/low/close from captured tool output.": "开/高/低/收,来自捕获的工具输出。", "Origin": "来源", "Outcome": "结果", "Per-channel calibration state, freshness, and residual correction": "各通道的校准状态、时效性与残差校正", "Plan": "计划", "Plan steps": "计划步骤", "Plugin": "插件", "Policy": "策略", "Positions & actions": "持仓与操作", "Price action": "价格行为", "Proposal": "提案", "Proposal status": "提案状态", "Pulse": "脉搏", "Ratio": "比值", "References & follow-ups": "参考与后续", "References (entities)": "参考(实体)", "Registry delta": "注册表变化", "Representative observations, capped for quick scanning.": "代表性观察,数量受限以便快速浏览。", "Requirements": "能力需求", "Research lens": "研究视图", "Residual": "残差", "Sampled history per channel, newest on the right": "按通道的采样历史,最新在右侧", "Selection delta": "选择变化", "Sentiment lens": "情绪视图", "Series": "序列", "Session analysis": "会话分析", "Signal strength": "信号强度", "Skipped slots": "跳过的采样点", "State": "状态", "Storyline and signal strength before drilling into positions and actions.": "先看叙事与信号强度,再深入持仓与操作。", "Streaming": "采样中", "The line of investigation and where the open questions concentrate.": "研究主线,以及待答问题的集中之处。", "The narrative arc and how strongly themes are trending.": "叙事走向,以及主题的趋势强度。", "Theme intensity": "主题强度", "Themes": "主题", "Tool": "工具", "Transport": "传输方式", "Transport, provenance and channel counts": "传输方式、来源与通道数量", "Trust": "信任级别", "Verified": "已核验", "Voices & concerns": "声音与关切", "Watchlist": "关注列表", "Who/what is in the conversation, and the concerns still open.": "谁/什么在被讨论,以及尚未解决的关切。", "Writable": "可写"}, - fr: {"A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "Un ratio inférieur à 1,0 signifie que la boucle d’échantillonnage ne tient pas sa cadence déclarée.", "Action": "Action", "After": "Après", "An unverified declaration has its writable channels demoted to read-only.": "Une déclaration non vérifiée voit ses canaux inscriptibles rétrogradés en lecture seule.", "Approval": "Approbation", "Autonomous governance": "Gouvernance autonome", "Autonomy": "Autonomie", "Before": "Avant", "Calibrated at": "Calibré le", "Calibration health": "État de calibration", "Calls (decisions)": "Recommandations (décisions)", "Candlestick": "Chandeliers", "Capability": "Capacité", "Capability adaptation": "Adaptation des capacités", "Channel": "Canal", "Channels": "Canaux", "Channels that have never been calibrated or whose calibration has expired are shown first.": "Les canaux jamais calibrés ou dont la calibration a expiré apparaissent en premier.", "Command": "Commande", "Commanded versus observed, best tracking first": "Commandé contre observé, meilleur suivi d’abord", "Concerns (open questions)": "Préoccupations (questions ouvertes)", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "Compté sur tous les canaux tracés. « près » signifie à moins de 5 % d’une borne déclarée.", "Days since": "Jours écoulés", "Decisions read as calls; action items as the execution checklist.": "Les décisions se lisent comme des recommandations ; les actions comme la liste d’exécution.", "Declared Hz": "Hz déclarés", "Desk brief": "Note de desk", "Device": "Appareil", "Dropped samples": "Échantillons perdus", "Entities as references, and recommended next prompts to advance the work.": "Entités comme références, et invites suivantes recommandées pour avancer.", "Entities in play and the open risks still to resolve.": "Entités concernées et risques ouverts à résoudre.", "Envelope, rate, staleness and quality observations · newest first": "Observations d’enveloppe, de débit, d’obsolescence et de qualité · les plus récentes d’abord", "Environment": "Environnement", "Environment, selected plugin tools, and orchestration order.": "Environnement, outils de plugin sélectionnés et ordre d’orchestration.", "Events paced out": "Événements limités", "Evidence": "Preuve", "Executable": "Exécutable", "Execution checklist": "Liste d’exécution", "Extracted from this session's tool/file output (not model-generated).": "Extrait des sorties d’outils/fichiers de cette session (non généré par le modèle).", "Failures": "Échecs", "Finance lens": "Vue finance", "Follow-ups": "Suivis", "Halt": "Arrêt", "How often each window sat inside, near, or outside its declared limits": "Fréquence à laquelle chaque fenêtre était dans, près de, ou hors de ses limites déclarées", "Inquiry brief": "Note d’enquête", "Insights carded as evidence, capped for fast review.": "Analyses présentées comme preuves, limitées pour une revue rapide.", "Instruments & counterparties": "Instruments et contreparties", "Latest capability decision": "Dernière décision de capacité", "Lifecycle timeline": "Chronologie du cycle de vie", "Line of inquiry": "Ligne d’enquête", "Location": "Emplacement", "Loop phase": "Phase de boucle", "Mean of each downsample window. Declared limits are listed per channel below.": "Moyenne de chaque fenêtre de sous-échantillonnage. Les limites déclarées figurent par canal ci-dessous.", "Mutation": "Mutation", "Narrative": "Récit", "Narrative pulse": "Pouls narratif", "Next recal due": "Prochaine recalibration", "Normalized error": "Erreur normalisée", "Normalized error is the residual as a share of the channel's declared span.": "L’erreur normalisée est le résidu en proportion de l’étendue déclarée du canal.", "OHLC extracted from captured session market data.": "OHLC extrait des données de marché capturées durant la session.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "File d’observations, état des propositions, décisions de politique et résultats du cycle de vie.", "Observations": "Observations", "Observed Hz": "Hz observés", "Observed rate against declared rate": "Débit observé par rapport au débit déclaré", "Open": "Ouvert", "Open risks": "Risques ouverts", "Open/high/low/close from captured tool output.": "Ouverture/haut/bas/clôture issus des sorties d’outils capturées.", "Origin": "Origine", "Outcome": "Résultat", "Per-channel calibration state, freshness, and residual correction": "État de calibration, fraîcheur et correction résiduelle par canal", "Plan": "Plan", "Plan steps": "Étapes du plan", "Plugin": "Plugin", "Policy": "Politique", "Positions & actions": "Positions et actions", "Price action": "Action des prix", "Proposal": "Proposition", "Proposal status": "Statut de la proposition", "Pulse": "Pouls", "Ratio": "Ratio", "References & follow-ups": "Références et suivis", "References (entities)": "Références (entités)", "Registry delta": "Delta du registre", "Representative observations, capped for quick scanning.": "Observations représentatives, limitées pour une lecture rapide.", "Requirements": "Exigences", "Research lens": "Vue recherche", "Residual": "Résidu", "Sampled history per channel, newest on the right": "Historique échantillonné par canal, le plus récent à droite", "Selection delta": "Delta de sélection", "Sentiment lens": "Vue sentiment", "Series": "Série", "Session analysis": "Analyse de session", "Signal strength": "Force du signal", "Skipped slots": "Créneaux manqués", "State": "État", "Storyline and signal strength before drilling into positions and actions.": "Récit et force du signal avant d’examiner positions et actions.", "Streaming": "Diffusion", "The line of investigation and where the open questions concentrate.": "La ligne d’investigation et où se concentrent les questions ouvertes.", "The narrative arc and how strongly themes are trending.": "L’arc narratif et l’intensité des tendances thématiques.", "Theme intensity": "Intensité des thèmes", "Themes": "Thèmes", "Tool": "Outil", "Transport": "Transport", "Transport, provenance and channel counts": "Transport, provenance et nombre de canaux", "Trust": "Confiance", "Verified": "Vérifié", "Voices & concerns": "Voix et préoccupations", "Watchlist": "Liste de suivi", "Who/what is in the conversation, and the concerns still open.": "Qui/quoi est dans la conversation, et les préoccupations encore ouvertes.", "Writable": "Inscriptible"}, - es: {"A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "Una relación inferior a 1,0 significa que el bucle de muestreo no mantiene su cadencia declarada.", "Action": "Acción", "After": "Después", "An unverified declaration has its writable channels demoted to read-only.": "Una declaración no verificada degrada sus canales escribibles a solo lectura.", "Approval": "Aprobación", "Autonomous governance": "Gobernanza autónoma", "Autonomy": "Autonomía", "Before": "Antes", "Calibrated at": "Calibrado el", "Calibration health": "Estado de calibración", "Calls (decisions)": "Recomendaciones (decisiones)", "Candlestick": "Velas", "Capability": "Capacidad", "Capability adaptation": "Adaptación de capacidades", "Channel": "Canal", "Channels": "Canales", "Channels that have never been calibrated or whose calibration has expired are shown first.": "Los canales nunca calibrados o con calibración vencida se muestran primero.", "Command": "Comando", "Commanded versus observed, best tracking first": "Comandado frente a observado, mejor seguimiento primero", "Concerns (open questions)": "Inquietudes (preguntas abiertas)", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "Contado en todos los canales graficados. «cerca» significa dentro del 5 % de un límite declarado.", "Days since": "Días desde", "Decisions read as calls; action items as the execution checklist.": "Las decisiones se leen como recomendaciones; las acciones como la lista de ejecución.", "Declared Hz": "Hz declarados", "Desk brief": "Informe de mesa", "Device": "Dispositivo", "Dropped samples": "Muestras descartadas", "Entities as references, and recommended next prompts to advance the work.": "Entidades como referencias y siguientes preguntas recomendadas para avanzar.", "Entities in play and the open risks still to resolve.": "Entidades implicadas y riesgos abiertos por resolver.", "Envelope, rate, staleness and quality observations · newest first": "Observaciones de envolvente, tasa, obsolescencia y calidad · las más recientes primero", "Environment": "Entorno", "Environment, selected plugin tools, and orchestration order.": "Entorno, herramientas de plugin seleccionadas y orden de orquestación.", "Events paced out": "Eventos limitados", "Evidence": "Evidencia", "Executable": "Ejecutable", "Execution checklist": "Lista de ejecución", "Extracted from this session's tool/file output (not model-generated).": "Extraído de la salida de herramientas/archivos de esta sesión (no generado por el modelo).", "Failures": "Fallos", "Finance lens": "Vista financiera", "Follow-ups": "Seguimientos", "Halt": "Parada", "How often each window sat inside, near, or outside its declared limits": "Con qué frecuencia cada ventana estuvo dentro, cerca o fuera de sus límites declarados", "Inquiry brief": "Informe de indagación", "Insights carded as evidence, capped for fast review.": "Hallazgos presentados como evidencia, limitados para revisión rápida.", "Instruments & counterparties": "Instrumentos y contrapartes", "Latest capability decision": "Última decisión de capacidad", "Lifecycle timeline": "Cronología del ciclo de vida", "Line of inquiry": "Línea de indagación", "Location": "Ubicación", "Loop phase": "Fase del bucle", "Mean of each downsample window. Declared limits are listed per channel below.": "Media de cada ventana de submuestreo. Los límites declarados se listan por canal abajo.", "Mutation": "Mutación", "Narrative": "Narrativa", "Narrative pulse": "Pulso narrativo", "Next recal due": "Próxima recalibración", "Normalized error": "Error normalizado", "Normalized error is the residual as a share of the channel's declared span.": "El error normalizado es el residuo como fracción del rango declarado del canal.", "OHLC extracted from captured session market data.": "OHLC extraído de los datos de mercado capturados en la sesión.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "Cola de observaciones, estado de propuestas, decisiones de política y resultados del ciclo de vida.", "Observations": "Observaciones", "Observed Hz": "Hz observados", "Observed rate against declared rate": "Tasa observada frente a la tasa declarada", "Open": "Abierto", "Open risks": "Riesgos abiertos", "Open/high/low/close from captured tool output.": "Apertura/máximo/mínimo/cierre desde la salida de herramientas capturada.", "Origin": "Origen", "Outcome": "Resultado", "Per-channel calibration state, freshness, and residual correction": "Estado de calibración, vigencia y corrección residual por canal", "Plan": "Plan", "Plan steps": "Pasos del plan", "Plugin": "Plugin", "Policy": "Política", "Positions & actions": "Posiciones y acciones", "Price action": "Acción del precio", "Proposal": "Propuesta", "Proposal status": "Estado de la propuesta", "Pulse": "Pulso", "Ratio": "Relación", "References & follow-ups": "Referencias y seguimientos", "References (entities)": "Referencias (entidades)", "Registry delta": "Delta del registro", "Representative observations, capped for quick scanning.": "Observaciones representativas, limitadas para lectura rápida.", "Requirements": "Requisitos", "Research lens": "Vista de investigación", "Residual": "Residuo", "Sampled history per channel, newest on the right": "Historial muestreado por canal, el más reciente a la derecha", "Selection delta": "Delta de selección", "Sentiment lens": "Vista de sentimiento", "Series": "Serie", "Session analysis": "Análisis de sesión", "Signal strength": "Fuerza de la señal", "Skipped slots": "Ranuras omitidas", "State": "Estado", "Storyline and signal strength before drilling into positions and actions.": "Narrativa y fuerza de la señal antes de entrar en posiciones y acciones.", "Streaming": "Transmisión", "The line of investigation and where the open questions concentrate.": "La línea de investigación y dónde se concentran las preguntas abiertas.", "The narrative arc and how strongly themes are trending.": "El arco narrativo y con qué fuerza se mueven los temas.", "Theme intensity": "Intensidad temática", "Themes": "Temas", "Tool": "Herramienta", "Transport": "Transporte", "Transport, provenance and channel counts": "Transporte, procedencia y número de canales", "Trust": "Confianza", "Verified": "Verificado", "Voices & concerns": "Voces e inquietudes", "Watchlist": "Lista de seguimiento", "Who/what is in the conversation, and the concerns still open.": "Quién/qué está en la conversación y las inquietudes aún abiertas.", "Writable": "Escribible"}, - ar: {"A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "نسبة أقل من 1.0 تعني أن حلقة أخذ العينات لا تحافظ على وتيرتها المعلنة.", "Action": "الإجراء", "After": "بعد", "An unverified declaration has its writable channels demoted to read-only.": "الإعلان غير المُتحقَّق منه تُخفَّض قنواته القابلة للكتابة إلى القراءة فقط.", "Approval": "الموافقة", "Autonomous governance": "الحكم الذاتي", "Autonomy": "الاستقلالية", "Before": "قبل", "Calibrated at": "تاريخ المعايرة", "Calibration health": "سلامة المعايرة", "Calls (decisions)": "التوصيات (القرارات)", "Candlestick": "الشموع", "Capability": "القدرة", "Capability adaptation": "تكييف القدرات", "Channel": "القناة", "Channels": "القنوات", "Channels that have never been calibrated or whose calibration has expired are shown first.": "تظهر أولاً القنوات التي لم تُعاير قط أو التي انتهت صلاحية معايرتها.", "Command": "الأمر", "Commanded versus observed, best tracking first": "المأمور مقابل المرصود، الأفضل تتبعاً أولاً", "Concerns (open questions)": "المخاوف (أسئلة مفتوحة)", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "محسوب على كل قناة مرسومة. \"قريب\" تعني داخل 5% من حد معلن.", "Days since": "الأيام المنقضية", "Decisions read as calls; action items as the execution checklist.": "القرارات تُقرأ كتوصيات؛ والإجراءات كقائمة تنفيذ.", "Declared Hz": "الهرتز المعلن", "Desk brief": "موجز المكتب", "Device": "الجهاز", "Dropped samples": "العينات المفقودة", "Entities as references, and recommended next prompts to advance the work.": "الكيانات كمراجع، والمطالبات التالية الموصى بها لدفع العمل.", "Entities in play and the open risks still to resolve.": "الكيانات المعنية والمخاطر المفتوحة التي لم تُحل.", "Envelope, rate, staleness and quality observations · newest first": "رصدات المغلف والمعدل والتقادم والجودة · الأحدث أولاً", "Environment": "البيئة", "Environment, selected plugin tools, and orchestration order.": "البيئة والأدوات المختارة وترتيب التنسيق.", "Events paced out": "الأحداث المُقيَّدة", "Evidence": "الدليل", "Executable": "قابل للتنفيذ", "Execution checklist": "قائمة التنفيذ", "Extracted from this session's tool/file output (not model-generated).": "مستخرج من مخرجات الأدوات/الملفات في هذه الجلسة (ليس من إنشاء النموذج).", "Failures": "الأعطال", "Finance lens": "منظور مالي", "Follow-ups": "المتابعات", "Halt": "إيقاف", "How often each window sat inside, near, or outside its declared limits": "عدد المرات التي كانت فيها كل نافذة داخل حدودها المعلنة أو قريبة منها أو خارجها", "Inquiry brief": "موجز الاستقصاء", "Insights carded as evidence, capped for fast review.": "الرؤى معروضة كأدلة، ومحدودة العدد للمراجعة السريعة.", "Instruments & counterparties": "الأدوات والأطراف المقابلة", "Latest capability decision": "أحدث قرار للقدرات", "Lifecycle timeline": "الخط الزمني لدورة الحياة", "Line of inquiry": "خط الاستقصاء", "Location": "الموقع", "Loop phase": "مرحلة الحلقة", "Mean of each downsample window. Declared limits are listed per channel below.": "متوسط كل نافذة تخفيض للعينات. الحدود المعلنة مدرجة لكل قناة أدناه.", "Mutation": "التغيير", "Narrative": "السرد", "Narrative pulse": "نبض السرد", "Next recal due": "موعد إعادة المعايرة", "Normalized error": "الخطأ المعياري", "Normalized error is the residual as a share of the channel's declared span.": "الخطأ المعياري هو المتبقي كنسبة من المدى المعلن للقناة.", "OHLC extracted from captured session market data.": "OHLC مستخرج من بيانات السوق المسجلة في الجلسة.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "قائمة الرصد وحالة المقترحات وقرارات السياسة ونتائج دورة الحياة.", "Observations": "الرصدات", "Observed Hz": "الهرتز المرصود", "Observed rate against declared rate": "المعدل المرصود مقابل المعدل المعلن", "Open": "مفتوح", "Open risks": "المخاطر المفتوحة", "Open/high/low/close from captured tool output.": "الافتتاح/الأعلى/الأدنى/الإغلاق من مخرجات الأدوات المسجلة.", "Origin": "المصدر", "Outcome": "النتيجة", "Per-channel calibration state, freshness, and residual correction": "حالة المعايرة وحداثتها وتصحيح المتبقي لكل قناة", "Plan": "الخطة", "Plan steps": "خطوات الخطة", "Plugin": "الملحق", "Policy": "السياسة", "Positions & actions": "المراكز والإجراءات", "Price action": "حركة السعر", "Proposal": "المقترح", "Proposal status": "حالة المقترح", "Pulse": "النبض", "Ratio": "النسبة", "References & follow-ups": "المراجع والمتابعات", "References (entities)": "المراجع (الكيانات)", "Registry delta": "فرق السجل", "Representative observations, capped for quick scanning.": "رصدات تمثيلية، محدودة العدد للقراءة السريعة.", "Requirements": "المتطلبات", "Research lens": "منظور بحثي", "Residual": "المتبقي", "Sampled history per channel, newest on the right": "سجل العينات لكل قناة، الأحدث على اليمين", "Selection delta": "فرق الاختيار", "Sentiment lens": "منظور المشاعر", "Series": "السلسلة", "Session analysis": "تحليل الجلسة", "Signal strength": "قوة الإشارة", "Skipped slots": "الفتحات المتخطاة", "State": "الحالة", "Storyline and signal strength before drilling into positions and actions.": "السرد وقوة الإشارة قبل التوسع في المراكز والإجراءات.", "Streaming": "بث", "The line of investigation and where the open questions concentrate.": "خط البحث وأين تتركز الأسئلة المفتوحة.", "The narrative arc and how strongly themes are trending.": "قوس السرد ومدى قوة اتجاه الموضوعات.", "Theme intensity": "شدة الموضوعات", "Themes": "الموضوعات", "Tool": "الأداة", "Transport": "النقل", "Transport, provenance and channel counts": "النقل والمنشأ وعدد القنوات", "Trust": "الثقة", "Verified": "مُتحقَّق", "Voices & concerns": "الأصوات والمخاوف", "Watchlist": "قائمة المتابعة", "Who/what is in the conversation, and the concerns still open.": "من/ما هو في المحادثة، والمخاوف التي لا تزال مفتوحة.", "Writable": "قابل للكتابة"}, - ru: {"A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "Отношение ниже 1,0 означает, что цикл выборки не выдерживает объявленный ритм.", "Action": "Действие", "After": "После", "An unverified declaration has its writable channels demoted to read-only.": "У непроверенного объявления записываемые каналы понижаются до только чтения.", "Approval": "Согласование", "Autonomous governance": "Автономное управление", "Autonomy": "Автономность", "Before": "До", "Calibrated at": "Калиброван", "Calibration health": "Состояние калибровки", "Calls (decisions)": "Рекомендации (решения)", "Candlestick": "Свечи", "Capability": "Возможность", "Capability adaptation": "Адаптация возможностей", "Channel": "Канал", "Channels": "Каналы", "Channels that have never been calibrated or whose calibration has expired are shown first.": "Каналы, которые никогда не калибровались или чья калибровка истекла, показаны первыми.", "Command": "Команда", "Commanded versus observed, best tracking first": "Заданное против наблюдаемого, лучшее отслеживание первым", "Concerns (open questions)": "Опасения (открытые вопросы)", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "Подсчитано по всем отображаемым каналам. «У границы» — в пределах 5% от объявленного предела.", "Days since": "Дней с тех пор", "Decisions read as calls; action items as the execution checklist.": "Решения читаются как рекомендации; действия — как чек-лист исполнения.", "Declared Hz": "Объявл. Гц", "Desk brief": "Сводка деска", "Device": "Устройство", "Dropped samples": "Отброшенные образцы", "Entities as references, and recommended next prompts to advance the work.": "Сущности как ссылки и рекомендуемые следующие запросы.", "Entities in play and the open risks still to resolve.": "Задействованные сущности и нерешённые риски.", "Envelope, rate, staleness and quality observations · newest first": "Наблюдения по огибающей, частоте, устареванию и качеству · сначала новые", "Environment": "Окружение", "Environment, selected plugin tools, and orchestration order.": "Окружение, выбранные инструменты плагинов и порядок оркестрации.", "Events paced out": "Событий подавлено", "Evidence": "Обоснование", "Executable": "Исполнимо", "Execution checklist": "Чек-лист исполнения", "Extracted from this session's tool/file output (not model-generated).": "Извлечено из вывода инструментов/файлов этой сессии (не сгенерировано моделью).", "Failures": "Сбои", "Finance lens": "Финансовый ракурс", "Follow-ups": "Продолжения", "Halt": "Останов", "How often each window sat inside, near, or outside its declared limits": "Как часто каждое окно было внутри, у границы или вне объявленных пределов", "Inquiry brief": "Сводка исследования", "Insights carded as evidence, capped for fast review.": "Инсайты как карточки-обоснования, ограничены для быстрого просмотра.", "Instruments & counterparties": "Инструменты и контрагенты", "Latest capability decision": "Последнее решение о возможностях", "Lifecycle timeline": "Хронология жизненного цикла", "Line of inquiry": "Линия исследования", "Location": "Расположение", "Loop phase": "Фаза цикла", "Mean of each downsample window. Declared limits are listed per channel below.": "Среднее по каждому окну прореживания. Объявленные пределы указаны по каналам ниже.", "Mutation": "Изменение", "Narrative": "Сюжет", "Narrative pulse": "Нарративный пульс", "Next recal due": "Следующая рекалибровка", "Normalized error": "Нормированная ошибка", "Normalized error is the residual as a share of the channel's declared span.": "Нормированная ошибка — остаток как доля объявленного диапазона канала.", "OHLC extracted from captured session market data.": "OHLC извлечён из рыночных данных, записанных в сессии.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "Очередь наблюдений, состояние предложений, решения политики и итоги жизненного цикла.", "Observations": "Наблюдения", "Observed Hz": "Наблюд. Гц", "Observed rate against declared rate": "Наблюдаемая частота против объявленной", "Open": "Открыт", "Open risks": "Открытые риски", "Open/high/low/close from captured tool output.": "Открытие/максимум/минимум/закрытие из записанного вывода инструментов.", "Origin": "Источник", "Outcome": "Результат", "Per-channel calibration state, freshness, and residual correction": "Состояние калибровки, актуальность и остаточная поправка по каналам", "Plan": "План", "Plan steps": "Шаги плана", "Plugin": "Плагин", "Policy": "Политика", "Positions & actions": "Позиции и действия", "Price action": "Ценовое движение", "Proposal": "Предложение", "Proposal status": "Статус предложения", "Pulse": "Пульс", "Ratio": "Отношение", "References & follow-ups": "Ссылки и продолжения", "References (entities)": "Ссылки (сущности)", "Registry delta": "Изменение реестра", "Representative observations, capped for quick scanning.": "Показательные наблюдения, ограничены для быстрого просмотра.", "Requirements": "Требования", "Research lens": "Исследовательский ракурс", "Residual": "Остаток", "Sampled history per channel, newest on the right": "История выборок по каналам, самое новое справа", "Selection delta": "Изменение выбора", "Sentiment lens": "Ракурс тональности", "Series": "Серия", "Session analysis": "Анализ сессии", "Signal strength": "Сила сигнала", "Skipped slots": "Пропущенные слоты", "State": "Состояние", "Storyline and signal strength before drilling into positions and actions.": "Сюжет и сила сигнала до перехода к позициям и действиям.", "Streaming": "Потоковая передача", "The line of investigation and where the open questions concentrate.": "Линия исследования и где сосредоточены открытые вопросы.", "The narrative arc and how strongly themes are trending.": "Нарративная дуга и насколько сильно растут темы.", "Theme intensity": "Интенсивность тем", "Themes": "Темы", "Tool": "Инструмент", "Transport": "Транспорт", "Transport, provenance and channel counts": "Транспорт, происхождение и число каналов", "Trust": "Доверие", "Verified": "Проверено", "Voices & concerns": "Голоса и опасения", "Watchlist": "Список наблюдения", "Who/what is in the conversation, and the concerns still open.": "Кто/что в разговоре и какие опасения остаются.", "Writable": "Записываемый"} + zh: {"> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **回归:已闭合的缺口再次复发。** 一次看起来成功的演进并未站住。这是本看板上唯一需要立即处理的发现。", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **仅快照。** 目前尚无可重建的因果历史,因此时间线是「缺席」而非「空白」。原因由管道贯通度中的 `策略决策` 一行说明;实时快照与贯通度表本身不受影响。", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **部分插件因内部缺陷被冻结。** 冻结的插件仍报告 `DRAFT`,而信任维度只做「打分」,因此若未同时注销,它仍可被选中——请查看 `可被选中` 列。", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **验证层级:L2(声明式适配)。** 观测被退役,只意味着某个候选**声明**自己提供该能力,并不意味着该能力被观测到确实生效。效果验证(L3)尚未接线,因此本看板上的任何闭合都不应被读作「已证实」。", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> 需要至少完成一个观测周期才会有内容。若持续为空,请检查调度器是否启用、`framework-evolution` watch 是否已 armed 且未静音。", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> 当一次环境观测导向一次能力决策时,才会写下一条剧集。目前尚无记录——这既可能是系统本就安静,也可能是管道更早就断了:**管道**页签会指出它断在哪一段,以及什么能解除阻塞。", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> 目前没有任何机制自动回收它们。每一个都占着一个工具名、出现在能力列表里,却不可被选中——注册表朝着没有任何需求能用的方向增长。", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> 这些提议未进入任何管道,因此不会出现在任何决策记录或观测中。是否准入是一项配置选择。", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "比值低于 1.0 表示采样循环未能维持其声明的节奏。", "Acquisition authority": "获取授权", "Acquisition lifecycle": "获取生命周期", "Action": "动作", "After": "变更后", "An unverified declaration has its writable channels demoted to read-only.": "未核验的声明,其可写通道会被降级为只读。", "Approval": "审批", "Autonomous governance": "自主治理", "Autonomy": "自主级别", "Before": "变更前", "CANDIDATE": "候选级", "Calibrated at": "校准时间", "Calibration health": "校准健康度", "Calls": "调用次数", "Calls (decisions)": "观点(决策)", "Candlestick": "K 线", "Capability": "能力", "Capability adaptation": "能力适配", "Capability observations": "能力观测", "Capability ownership": "能力归属", "Capability topology": "能力拓扑", "Change": "变化", "Channel": "通道", "Channels": "通道数", "Channels that have never been calibrated or whose calibration has expired are shown first.": "从未校准或校准已过期的通道排在最前。", "Command": "命令", "Commanded versus observed, best tracking first": "命令值与实测值对比,跟随最好者在前", "Composition": "组成", "Concerns (open questions)": "关切(待答问题)", "Confidence": "置信度", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "统计所有绘制通道。“接近”指处于声明边界的 5% 以内。", "Cycles run": "已运行周期", "DRAFT": "草稿级", "Days since": "距今天数", "Decision": "决策", "Decisions read as calls; action items as the execution checklist.": "决策即观点,行动项即执行清单。", "Declared Hz": "声明频率 (Hz)", "Desk brief": "交易台简报", "Device": "设备", "Dropped samples": "丢弃的样本", "Each row names one blocked segment and the change that would unblock it.": "每一行指出一个受阻环节,以及能解除阻塞的那项变更。", "Effect verification (L3)": "效果验证(L3)", "Entities as references, and recommended next prompts to advance the work.": "实体作为参考,并给出推进工作的后续追问。", "Entities in play and the open risks still to resolve.": "涉及的实体,以及尚未解决的敞口风险。", "Envelope, rate, staleness and quality observations · newest first": "包络、速率、失联与质量观测 · 最新在前", "Environment": "环境", "Environment to framework": "环境 → 框架", "Environment, selected plugin tools, and orchestration order.": "环境、已选插件工具及编排顺序。", "Error rate": "错误率", "Events paced out": "被配速抑制的事件", "Ever used": "是否用过", "Evidence": "证据", "Evidence admission": "证据准入", "Evolution": "演进", "Evolution timeline": "演进时间线", "Executable": "可执行", "Execution checklist": "执行清单", "Extracted from this session's tool/file output (not model-generated).": "数据来自本次会话的工具/文件产物(非模型生成)。", "Failures": "失败次数", "Fiber": "Fiber 状态", "Fiber state changes since the previous cycle, including load retries.": "自上一周期以来的 Fiber 状态变化,含加载重试。", "Finance lens": "金融视图", "Follow-ups": "后续事项", "Framework change": "框架变更", "Framework changes as they happened, from runtime probes.": "来自运行时探针的框架变更实况。", "Framework evolution": "框架演进", "Framework size and how much of the evolution pipeline shows runtime evidence.": "框架规模,以及演进管道中有多少环节呈现运行时证据。", "From": "从", "Frozen plugins": "已冻结插件", "Gap closure": "缺口闭合", "Halt": "可急停", "How closures are verified": "闭合是如何验证的", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "框架中有多少是它自己长出来的,以及演进管道中有多少环节呈现运行时证据。", "How often each window sat inside, near, or outside its declared limits": "各窗口处于声明限值内、接近边界或越界的频次", "Inquiry brief": "研究简报", "Insights carded as evidence, capped for fast review.": "洞察以证据卡呈现,数量受限以便快速浏览。", "Instruments & counterparties": "标的与交易对手", "Kept": "保留", "Latest capability decision": "最新能力决策", "Lifecycle records": "生命周期记录", "Lifecycle timeline": "生命周期时间线", "Lifecycle transitions": "生命周期迁移", "Line of inquiry": "研究主线", "Live activity": "实时动态", "Location": "位置", "Loop phase": "循环阶段", "Mean of each downsample window. Declared limits are listed per channel below.": "每个降采样窗口的均值。各通道的声明限值见下方。", "Model's reasoning": "模型的推理", "Mutation": "变更", "Narrative": "叙事", "Narrative pulse": "叙事脉搏", "Needs attention": "需要关注", "Next recal due": "下次校准期限", "Next step": "下一步", "No causal history yet": "尚无因果历史", "Normalized error": "归一化误差", "Normalized error is the residual as a share of the channel's declared span.": "归一化误差是残差占该通道声明量程的比例。", "Not yet observed": "尚未观测", "Nothing has driven a framework change, so there is no episode to narrate.": "尚无任何事驱动过框架变更,因此没有可讲述的剧集。", "OHLC extracted from captured session market data.": "OHLC 提取自本次会话捕获的行情数据。", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "观测待办、提案状态、策略决策与生命周期结果。", "Observations": "观测数", "Observed Hz": "实测频率 (Hz)", "Observed rate against declared rate": "实测速率与声明速率对比", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "单一全局命名空间,先注册者胜。挑战者会被记录,绝不静默丢弃。", "Open": "已连接", "Open risks": "敞口风险", "Open/high/low/close from captured tool output.": "开/高/低/收,来自捕获的工具输出。", "Origin": "来源", "Outcome": "结果", "PRODUCTION": "生产级", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "逐条剧集:触发源、决策、变更,以及缺口是否闭合。", "Per-channel calibration state, freshness, and residual correction": "各通道的校准状态、时效性与残差校正", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "逐段运行时证据。模块存在并不等于有任何代码调用它。", "Pipeline": "管道", "Pipeline evidence": "管道证据", "Pipeline reachability": "管道贯通度", "Plan": "计划", "Plan steps": "计划步骤", "Plugin": "插件", "Plugin roster and trust": "插件名册与信任", "Plugins": "插件数", "Plugins by origin": "按来源分布的插件", "Plugins by trust class": "按信任等级分布的插件", "Policy": "策略", "Policy decisions": "策略决策", "Positions & actions": "持仓与操作", "Posture": "态势", "Price action": "价格行为", "Proposal": "提案", "Proposal status": "提案状态", "Proposed, not admitted": "已提议,未准入", "Pulse": "脉搏", "Quarantine feed": "隔离进料", "Ratio": "比值", "Read live from the registry and trust ledger every cycle.": "每个周期从注册表与信任账本实时读取。", "Recent episodes": "近期剧集", "Reclaim candidates": "可回收候选", "Reclaimable": "可回收", "References & follow-ups": "参考与后续", "References (entities)": "参考(实体)", "Registry": "注册表", "Registry delta": "注册表变化", "Registry version": "注册表版本", "Regressions": "回归", "Rejected": "被拒", "Representative observations, capped for quick scanning.": "代表性观察,数量受限以便快速浏览。", "Requirements": "能力需求", "Research lens": "研究视图", "Residual": "残差", "Runtime evidence": "运行时证据", "Sampled history per channel, newest on the right": "按通道的采样历史,最新在右侧", "Segment": "管道段", "Segments by status": "按状态分布的管道段", "Selectable": "可被选中", "Selection delta": "选择变化", "Self-acquired": "自获取", "Self-acquired plugins that are registered but unselectable or never once used.": "已注册但不可被选中、或从未被使用过的自获取插件。", "Sentiment lens": "情绪视图", "Series": "序列", "Session analysis": "会话分析", "Signal strength": "信号强度", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "表明某处长错了、或被扣下未放行的信号。无论打开哪个页签都会显示。", "Skipped slots": "跳过的采样点", "State": "状态", "Storyline and signal strength before drilling into positions and actions.": "先看叙事与信号强度,再深入持仓与操作。", "Streaming": "采样中", "Suggested next steps": "建议的下一步", "The line of investigation and where the open questions concentrate.": "研究主线,以及待答问题的集中之处。", "The narrative arc and how strongly themes are trending.": "叙事走向,以及主题的趋势强度。", "The world model asked for these capabilities and nothing took them up.": "世界模型请求了这些能力,但无人受理。", "Theme intensity": "主题强度", "Themes": "主题", "This board reports how the framework changes itself. Nothing has been recorded yet.": "本看板报告框架如何改变自身。目前尚无任何记录。", "To": "到", "Tool": "工具", "Tool-name conflicts": "工具名冲突", "Tools": "工具数", "Transport": "传输方式", "Transport, provenance and channel counts": "传输方式、来源与通道数量", "Trust": "信任级别", "Trust accrual": "信任累积", "Trust class": "信任语义", "Unselectable reclamation": "不可选回收", "VERIFIED": "已验证级", "Verified": "已核验", "Verified by": "验证依据", "Voices & concerns": "声音与关切", "Watchlist": "关注列表", "What changed in the environment, and what the framework did about it.": "环境发生了什么变化,框架又为此做了什么。", "Which plugin owns which tool, and which capability that tool provides.": "哪个插件拥有哪个工具,以及该工具提供什么能力。", "Who/what is in the conversation, and the concerns still open.": "谁/什么在被讨论,以及尚未解决的关切。", "Why": "原因", "Why not admitted": "未准入原因", "Why this page is empty": "这个页面为何是空的", "World-model driver": "世界模型驱动器", "Writable": "可写", "aborted": "已中断", "accruing": "正在累积", "active": "运行中", "appeared": "新出现", "armed": "已就绪", "assess_compatibility": "评估兼容性", "built_in": "内置", "capability_expand": "扩展能力", "committed": "已定论", "conformance": "合规", "declared_fitness": "声明式适配", "disable": "停用", "disposed": "已释放", "environment_probe": "环境探测", "failed": "已失败", "frozen": "已冻结", "gone": "已消失", "idle": "空闲无变化", "install": "安装", "loading": "加载中", "manual": "人工", "moved": "已迁移", "new_unproven": "新,未验证", "no": "否", "no_evidence": "无证据", "none": "无", "not_admitted": "未准入", "not_applicable": "不适用", "observe_only": "仅观察", "observed_effect": "观测效果", "open": "进行中", "pending": "待启", "reload": "重载", "remove": "移除", "reopened": "已复发", "resolved": "已闭合", "rollback": "回滚", "runtime": "运行时", "self_acquired": "自获取", "still_open": "仍未闭合", "trusted": "已信任", "unknown": "未知", "unknown_tool": "未知工具", "unloading": "卸载中", "unscheduled": "未调度", "unverifiable": "无法核实", "unverified": "未验证", "waiting": "等待首个周期", "watching": "监视中", "wired": "已贯通", "world_model": "世界模型", "yes": "是"}, + fr: {"> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **Régression : un écart comblé s'est reproduit.** Une évolution qui semblait réussie n'a pas tenu. C'est le seul constat de ce tableau qui exige une attention immédiate.", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **Instantané seulement.** Aucun historique causal à reconstruire pour l'instant : la chronologie est absente, non vide. La raison est indiquée par la ligne `Décisions de politique` sous la couverture du pipeline ; l'instantané et le tableau de couverture ne sont pas affectés.", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **Certains plugins sont gelés par un défaut interne.** Un plugin gelé signale toujours `DRAFT`, et la dimension de confiance ne fait que *noter*, donc il reste sélectionnable tant qu'il n'est pas également désenregistré — voir la colonne `Sélectionnable`.", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **Niveau de vérification : L2 (aptitude déclarée).** Une observation retirée signifie qu'un candidat a *déclaré* fournir la capacité, non que la capacité a été observée en fonctionnement. La vérification d'effet (L3) n'est pas câblée, donc aucune clôture de ce tableau ne doit être lue comme prouvée.", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> Un cycle d'observation doit s'achever avant qu'il y ait quoi que ce soit à montrer. Si cela persiste, vérifiez que le planificateur est actif et que la surveillance `framework-evolution` est armée et non silencée.", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> Un épisode est écrit lorsqu'une observation de l'environnement conduit à une décision de capacité. Aucun n'a été enregistré : soit le système est calme, soit le pipeline s'arrête plus tôt — l'onglet **Pipeline** nomme le segment où il s'arrête et ce qui le débloquerait.", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> Rien ne les récupère automatiquement. Chacun occupe un nom d'outil et figure dans la liste des capacités sans être sélectionnable : le registre grandit dans une direction qu'aucune exigence ne peut utiliser.", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> Ces propositions n'ont intégré aucun pipeline : elles n'apparaissent donc dans aucun enregistrement de décision ni observation. Les admettre est un choix de configuration.", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "Un ratio inférieur à 1,0 signifie que la boucle d’échantillonnage ne tient pas sa cadence déclarée.", "Acquisition authority": "Autorité d'acquisition", "Acquisition lifecycle": "Cycle de vie d'acquisition", "Action": "Action", "After": "Après", "An unverified declaration has its writable channels demoted to read-only.": "Une déclaration non vérifiée voit ses canaux inscriptibles rétrogradés en lecture seule.", "Approval": "Approbation", "Autonomous governance": "Gouvernance autonome", "Autonomy": "Autonomie", "Before": "Avant", "CANDIDATE": "Candidat", "Calibrated at": "Calibré le", "Calibration health": "État de calibration", "Calls": "Appels", "Calls (decisions)": "Recommandations (décisions)", "Candlestick": "Chandeliers", "Capability": "Capacité", "Capability adaptation": "Adaptation des capacités", "Capability observations": "Observations de capacités", "Capability ownership": "Propriété des capacités", "Capability topology": "Topologie des capacités", "Change": "Changement", "Channel": "Canal", "Channels": "Canaux", "Channels that have never been calibrated or whose calibration has expired are shown first.": "Les canaux jamais calibrés ou dont la calibration a expiré apparaissent en premier.", "Command": "Commande", "Commanded versus observed, best tracking first": "Commandé contre observé, meilleur suivi d’abord", "Composition": "Composition", "Concerns (open questions)": "Préoccupations (questions ouvertes)", "Confidence": "Confiance", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "Compté sur tous les canaux tracés. « près » signifie à moins de 5 % d’une borne déclarée.", "Cycles run": "Cycles exécutés", "DRAFT": "Brouillon", "Days since": "Jours écoulés", "Decision": "Décision", "Decisions read as calls; action items as the execution checklist.": "Les décisions se lisent comme des recommandations ; les actions comme la liste d’exécution.", "Declared Hz": "Hz déclarés", "Desk brief": "Note de desk", "Device": "Appareil", "Dropped samples": "Échantillons perdus", "Each row names one blocked segment and the change that would unblock it.": "Chaque ligne nomme un segment bloqué et le changement qui le débloquerait.", "Effect verification (L3)": "Vérification d'effet (L3)", "Entities as references, and recommended next prompts to advance the work.": "Entités comme références, et invites suivantes recommandées pour avancer.", "Entities in play and the open risks still to resolve.": "Entités concernées et risques ouverts à résoudre.", "Envelope, rate, staleness and quality observations · newest first": "Observations d’enveloppe, de débit, d’obsolescence et de qualité · les plus récentes d’abord", "Environment": "Environnement", "Environment to framework": "De l'environnement au framework", "Environment, selected plugin tools, and orchestration order.": "Environnement, outils de plugin sélectionnés et ordre d’orchestration.", "Error rate": "Taux d'erreur", "Events paced out": "Événements limités", "Ever used": "Déjà utilisé", "Evidence": "Preuve", "Evidence admission": "Admission des preuves", "Evolution": "Évolution", "Evolution timeline": "Chronologie de l'évolution", "Executable": "Exécutable", "Execution checklist": "Liste d’exécution", "Extracted from this session's tool/file output (not model-generated).": "Extrait des sorties d’outils/fichiers de cette session (non généré par le modèle).", "Failures": "Échecs", "Fiber": "Fibre", "Fiber state changes since the previous cycle, including load retries.": "Changements d'état de fiber depuis le cycle précédent, y compris les tentatives de chargement.", "Finance lens": "Vue finance", "Follow-ups": "Suivis", "Framework change": "Changement du framework", "Framework changes as they happened, from runtime probes.": "Changements du framework en temps réel, via les sondes d'exécution.", "Framework evolution": "Évolution du framework", "Framework size and how much of the evolution pipeline shows runtime evidence.": "Taille du framework et part du pipeline d'évolution qui présente des preuves d'exécution.", "From": "De", "Frozen plugins": "Plugins gelés", "Gap closure": "Clôture de l'écart", "Halt": "Arrêt", "How closures are verified": "Comment les clôtures sont vérifiées", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "Quelle part du framework il a fait croître lui-même, et quelle part du pipeline présente des preuves d'exécution.", "How often each window sat inside, near, or outside its declared limits": "Fréquence à laquelle chaque fenêtre était dans, près de, ou hors de ses limites déclarées", "Inquiry brief": "Note d’enquête", "Insights carded as evidence, capped for fast review.": "Analyses présentées comme preuves, limitées pour une revue rapide.", "Instruments & counterparties": "Instruments et contreparties", "Kept": "Conservé", "Latest capability decision": "Dernière décision de capacité", "Lifecycle records": "Enregistrements de cycle de vie", "Lifecycle timeline": "Chronologie du cycle de vie", "Lifecycle transitions": "Transitions de cycle de vie", "Line of inquiry": "Ligne d’enquête", "Live activity": "Activité en direct", "Location": "Emplacement", "Loop phase": "Phase de boucle", "Mean of each downsample window. Declared limits are listed per channel below.": "Moyenne de chaque fenêtre de sous-échantillonnage. Les limites déclarées figurent par canal ci-dessous.", "Model's reasoning": "Raisonnement du modèle", "Mutation": "Mutation", "Narrative": "Récit", "Narrative pulse": "Pouls narratif", "Needs attention": "Requiert attention", "Next recal due": "Prochaine recalibration", "Next step": "Étape suivante", "No causal history yet": "Pas encore d'historique causal", "Normalized error": "Erreur normalisée", "Normalized error is the residual as a share of the channel's declared span.": "L’erreur normalisée est le résidu en proportion de l’étendue déclarée du canal.", "Not yet observed": "Pas encore observé", "Nothing has driven a framework change, so there is no episode to narrate.": "Rien n'a encore déclenché de changement du framework : il n'y a donc aucun épisode à raconter.", "OHLC extracted from captured session market data.": "OHLC extrait des données de marché capturées durant la session.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "File d’observations, état des propositions, décisions de politique et résultats du cycle de vie.", "Observations": "Observations", "Observed Hz": "Hz observés", "Observed rate against declared rate": "Débit observé par rapport au débit déclaré", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "Un espace de noms global unique, arbitré au premier arrivé. Le concurrent est enregistré, jamais supprimé en silence.", "Open": "Ouvert", "Open risks": "Risques ouverts", "Open/high/low/close from captured tool output.": "Ouverture/haut/bas/clôture issus des sorties d’outils capturées.", "Origin": "Origine", "Outcome": "Résultat", "PRODUCTION": "Production", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "Par épisode : le déclencheur, la décision, le changement, et si l'écart a été comblé.", "Per-channel calibration state, freshness, and residual correction": "État de calibration, fraîcheur et correction résiduelle par canal", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "Preuves d'exécution par segment. L'existence d'un module ne prouve pas qu'il soit appelé.", "Pipeline": "Pipeline", "Pipeline evidence": "Preuves du pipeline", "Pipeline reachability": "Accessibilité du pipeline", "Plan": "Plan", "Plan steps": "Étapes du plan", "Plugin": "Plugin", "Plugin roster and trust": "Registre des plugins et confiance", "Plugins": "Plugins", "Plugins by origin": "Plugins par origine", "Plugins by trust class": "Plugins par classe de confiance", "Policy": "Politique", "Policy decisions": "Décisions de politique", "Positions & actions": "Positions et actions", "Posture": "Posture", "Price action": "Action des prix", "Proposal": "Proposition", "Proposal status": "Statut de la proposition", "Proposed, not admitted": "Proposé, non admis", "Pulse": "Pouls", "Quarantine feed": "Flux de quarantaine", "Ratio": "Ratio", "Read live from the registry and trust ledger every cycle.": "Lu en direct depuis le registre et le registre de confiance à chaque cycle.", "Recent episodes": "Épisodes récents", "Reclaim candidates": "Candidats à la récupération", "Reclaimable": "Récupérable", "References & follow-ups": "Références et suivis", "References (entities)": "Références (entités)", "Registry": "Registre", "Registry delta": "Delta du registre", "Registry version": "Version du registre", "Regressions": "Régressions", "Rejected": "Rejeté", "Representative observations, capped for quick scanning.": "Observations représentatives, limitées pour une lecture rapide.", "Requirements": "Exigences", "Research lens": "Vue recherche", "Residual": "Résidu", "Runtime evidence": "Preuve d'exécution", "Sampled history per channel, newest on the right": "Historique échantillonné par canal, le plus récent à droite", "Segment": "Segment", "Segments by status": "Segments par statut", "Selectable": "Sélectionnable", "Selection delta": "Delta de sélection", "Self-acquired": "Auto-acquis", "Self-acquired plugins that are registered but unselectable or never once used.": "Plugins auto-acquis qui sont enregistrés mais non sélectionnables, ou jamais utilisés une seule fois.", "Sentiment lens": "Vue sentiment", "Series": "Série", "Session analysis": "Analyse de session", "Signal strength": "Force du signal", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "Signaux indiquant qu'une évolution a mal tourné ou a été retenue. Affichés quel que soit l'onglet ouvert.", "Skipped slots": "Créneaux manqués", "State": "État", "Storyline and signal strength before drilling into positions and actions.": "Récit et force du signal avant d’examiner positions et actions.", "Streaming": "Diffusion", "Suggested next steps": "Prochaines étapes suggérées", "The line of investigation and where the open questions concentrate.": "La ligne d’investigation et où se concentrent les questions ouvertes.", "The narrative arc and how strongly themes are trending.": "L’arc narratif et l’intensité des tendances thématiques.", "The world model asked for these capabilities and nothing took them up.": "Le modèle du monde a demandé ces capacités et personne ne les a prises en charge.", "Theme intensity": "Intensité des thèmes", "Themes": "Thèmes", "This board reports how the framework changes itself. Nothing has been recorded yet.": "Ce tableau rend compte de la façon dont le framework se modifie lui-même. Rien n'a encore été enregistré.", "To": "Vers", "Tool": "Outil", "Tool-name conflicts": "Conflits de noms d'outils", "Tools": "Outils", "Transport": "Transport", "Transport, provenance and channel counts": "Transport, provenance et nombre de canaux", "Trust": "Confiance", "Trust accrual": "Accumulation de confiance", "Trust class": "Classe de confiance", "Unselectable reclamation": "Récupération non sélectionnable", "VERIFIED": "Vérifié", "Verified": "Vérifié", "Verified by": "Vérifié par", "Voices & concerns": "Voix et préoccupations", "Watchlist": "Liste de suivi", "What changed in the environment, and what the framework did about it.": "Ce qui a changé dans l'environnement, et ce que le framework a fait en réponse.", "Which plugin owns which tool, and which capability that tool provides.": "Quel plugin possède quel outil, et quelle capacité cet outil fournit.", "Who/what is in the conversation, and the concerns still open.": "Qui/quoi est dans la conversation, et les préoccupations encore ouvertes.", "Why": "Pourquoi", "Why not admitted": "Motif de non-admission", "Why this page is empty": "Pourquoi cette page est vide", "World-model driver": "Pilote du modèle du monde", "Writable": "Inscriptible", "aborted": "Abandonné", "accruing": "En accumulation", "active": "Actif", "appeared": "Apparu", "armed": "Armé", "assess_compatibility": "Évaluer la compatibilité", "built_in": "Intégré", "capability_expand": "Étendre les capacités", "committed": "Conclu", "conformance": "Conformité", "declared_fitness": "Aptitude déclarée", "disable": "Désactiver", "disposed": "Libéré", "environment_probe": "Sonde d'environnement", "failed": "Échoué", "frozen": "Gelé", "gone": "Disparu", "idle": "Au repos", "install": "Installer", "loading": "Chargement", "manual": "Manuel", "moved": "Déplacé", "new_unproven": "Nouveau, non éprouvé", "no": "Non", "no_evidence": "Aucune preuve", "none": "Aucun", "not_admitted": "Non admis", "not_applicable": "Sans objet", "observe_only": "Observer seulement", "observed_effect": "Effet observé", "open": "Ouvert", "pending": "En attente", "reload": "Recharger", "remove": "Supprimer", "reopened": "Réouvert", "resolved": "Résolu", "rollback": "Annuler", "runtime": "Exécution", "self_acquired": "Auto-acquis", "still_open": "Toujours ouvert", "trusted": "De confiance", "unknown": "Inconnu", "unknown_tool": "Outil inconnu", "unloading": "Déchargement", "unscheduled": "Non planifié", "unverifiable": "Invérifiable", "unverified": "Non vérifié", "waiting": "En attente", "watching": "En surveillance", "wired": "Câblé", "world_model": "Modèle du monde", "yes": "Oui"}, + es: {"> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **Regresión: una brecha cerrada ha vuelto a aparecer.** Una evolución que parecía exitosa no se sostuvo. Es el único hallazgo de este panel que exige atención inmediata.", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **Solo instantánea.** Todavía no hay historia causal que reconstruir, por lo que la cronología está ausente, no vacía. El motivo lo indica la fila `Decisiones de política` bajo la cobertura del pipeline; la instantánea y la tabla de cobertura no se ven afectadas.", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **Algunos plugins están congelados por un defecto interno.** Un plugin congelado sigue informando `DRAFT`, y la dimensión de confianza solo *puntúa*, por lo que permanece seleccionable a menos que también se desregistre — consulte la columna `Seleccionable`.", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **Nivel de verificación: L2 (aptitud declarada).** Una observación retirada significa que un candidato *declaró* que proporciona la capacidad, no que se observara funcionando. La verificación de efecto (L3) no está conectada, así que ningún cierre de este panel debe leerse como probado.", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> Debe completarse un ciclo de observación antes de que haya algo que mostrar. Si persiste, compruebe que el planificador está activo y que la vigilancia `framework-evolution` está armada y no silenciada.", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> Un episodio se escribe cuando una observación del entorno conduce a una decisión de capacidad. No se ha registrado ninguno: o el sistema está tranquilo o el pipeline se detiene antes — la pestaña **Pipeline** nombra el segmento donde se detiene y qué lo desbloquearía.", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> Nada los recupera automáticamente. Cada uno ocupa un nombre de herramienta y aparece en la lista de capacidades sin ser seleccionable: el registro crece en una dirección que ningún requisito puede usar.", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> Estas propuestas no entraron en ningún pipeline, por lo que no aparecen en ningún registro de decisión ni observación. Admitirlas es una elección de configuración.", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "Una relación inferior a 1,0 significa que el bucle de muestreo no mantiene su cadencia declarada.", "Acquisition authority": "Autoridad de adquisición", "Acquisition lifecycle": "Ciclo de vida de adquisición", "Action": "Acción", "After": "Después", "An unverified declaration has its writable channels demoted to read-only.": "Una declaración no verificada degrada sus canales escribibles a solo lectura.", "Approval": "Aprobación", "Autonomous governance": "Gobernanza autónoma", "Autonomy": "Autonomía", "Before": "Antes", "CANDIDATE": "Candidato", "Calibrated at": "Calibrado el", "Calibration health": "Estado de calibración", "Calls": "Llamadas", "Calls (decisions)": "Recomendaciones (decisiones)", "Candlestick": "Velas", "Capability": "Capacidad", "Capability adaptation": "Adaptación de capacidades", "Capability observations": "Observaciones de capacidad", "Capability ownership": "Propiedad de capacidades", "Capability topology": "Topología de capacidades", "Change": "Cambio", "Channel": "Canal", "Channels": "Canales", "Channels that have never been calibrated or whose calibration has expired are shown first.": "Los canales nunca calibrados o con calibración vencida se muestran primero.", "Command": "Comando", "Commanded versus observed, best tracking first": "Comandado frente a observado, mejor seguimiento primero", "Composition": "Composición", "Concerns (open questions)": "Inquietudes (preguntas abiertas)", "Confidence": "Confianza", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "Contado en todos los canales graficados. «cerca» significa dentro del 5 % de un límite declarado.", "Cycles run": "Ciclos ejecutados", "DRAFT": "Borrador", "Days since": "Días desde", "Decision": "Decisión", "Decisions read as calls; action items as the execution checklist.": "Las decisiones se leen como recomendaciones; las acciones como la lista de ejecución.", "Declared Hz": "Hz declarados", "Desk brief": "Informe de mesa", "Device": "Dispositivo", "Dropped samples": "Muestras descartadas", "Each row names one blocked segment and the change that would unblock it.": "Cada fila nombra un segmento bloqueado y el cambio que lo desbloquearía.", "Effect verification (L3)": "Verificación de efecto (L3)", "Entities as references, and recommended next prompts to advance the work.": "Entidades como referencias y siguientes preguntas recomendadas para avanzar.", "Entities in play and the open risks still to resolve.": "Entidades implicadas y riesgos abiertos por resolver.", "Envelope, rate, staleness and quality observations · newest first": "Observaciones de envolvente, tasa, obsolescencia y calidad · las más recientes primero", "Environment": "Entorno", "Environment to framework": "Del entorno al framework", "Environment, selected plugin tools, and orchestration order.": "Entorno, herramientas de plugin seleccionadas y orden de orquestación.", "Error rate": "Tasa de error", "Events paced out": "Eventos limitados", "Ever used": "Alguna vez usado", "Evidence": "Evidencia", "Evidence admission": "Admisión de evidencia", "Evolution": "Evolución", "Evolution timeline": "Cronología de la evolución", "Executable": "Ejecutable", "Execution checklist": "Lista de ejecución", "Extracted from this session's tool/file output (not model-generated).": "Extraído de la salida de herramientas/archivos de esta sesión (no generado por el modelo).", "Failures": "Fallos", "Fiber": "Fibra", "Fiber state changes since the previous cycle, including load retries.": "Cambios de estado de fiber desde el ciclo anterior, incluidos los reintentos de carga.", "Finance lens": "Vista financiera", "Follow-ups": "Seguimientos", "Framework change": "Cambio del framework", "Framework changes as they happened, from runtime probes.": "Cambios del framework en tiempo real, desde sondas de ejecución.", "Framework evolution": "Evolución del framework", "Framework size and how much of the evolution pipeline shows runtime evidence.": "Tamaño del framework y qué parte del pipeline de evolución muestra evidencia en ejecución.", "From": "Desde", "Frozen plugins": "Plugins congelados", "Gap closure": "Cierre de la brecha", "Halt": "Parada", "How closures are verified": "Cómo se verifican los cierres", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "Cuánto del framework hizo crecer por sí mismo y cuánto del pipeline muestra evidencia de ejecución.", "How often each window sat inside, near, or outside its declared limits": "Con qué frecuencia cada ventana estuvo dentro, cerca o fuera de sus límites declarados", "Inquiry brief": "Informe de indagación", "Insights carded as evidence, capped for fast review.": "Hallazgos presentados como evidencia, limitados para revisión rápida.", "Instruments & counterparties": "Instrumentos y contrapartes", "Kept": "Conservado", "Latest capability decision": "Última decisión de capacidad", "Lifecycle records": "Registros de ciclo de vida", "Lifecycle timeline": "Cronología del ciclo de vida", "Lifecycle transitions": "Transiciones de ciclo de vida", "Line of inquiry": "Línea de indagación", "Live activity": "Actividad en vivo", "Location": "Ubicación", "Loop phase": "Fase del bucle", "Mean of each downsample window. Declared limits are listed per channel below.": "Media de cada ventana de submuestreo. Los límites declarados se listan por canal abajo.", "Model's reasoning": "Razonamiento del modelo", "Mutation": "Mutación", "Narrative": "Narrativa", "Narrative pulse": "Pulso narrativo", "Needs attention": "Requiere atención", "Next recal due": "Próxima recalibración", "Next step": "Siguiente paso", "No causal history yet": "Aún no hay historia causal", "Normalized error": "Error normalizado", "Normalized error is the residual as a share of the channel's declared span.": "El error normalizado es el residuo como fracción del rango declarado del canal.", "Not yet observed": "Aún no observado", "Nothing has driven a framework change, so there is no episode to narrate.": "Nada ha impulsado todavía un cambio del framework, por lo que no hay ningún episodio que narrar.", "OHLC extracted from captured session market data.": "OHLC extraído de los datos de mercado capturados en la sesión.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "Cola de observaciones, estado de propuestas, decisiones de política y resultados del ciclo de vida.", "Observations": "Observaciones", "Observed Hz": "Hz observados", "Observed rate against declared rate": "Tasa observada frente a la tasa declarada", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "Un único espacio de nombres global, arbitrado por orden de llegada. El aspirante queda registrado, nunca se descarta en silencio.", "Open": "Abierto", "Open risks": "Riesgos abiertos", "Open/high/low/close from captured tool output.": "Apertura/máximo/mínimo/cierre desde la salida de herramientas capturada.", "Origin": "Origen", "Outcome": "Resultado", "PRODUCTION": "Producción", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "Por episodio: el desencadenante, la decisión, el cambio y si la brecha se cerró.", "Per-channel calibration state, freshness, and residual correction": "Estado de calibración, vigencia y corrección residual por canal", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "Evidencia en ejecución por segmento. Que un módulo exista no prueba que algo lo invoque.", "Pipeline": "Pipeline", "Pipeline evidence": "Evidencia del pipeline", "Pipeline reachability": "Alcanzabilidad del pipeline", "Plan": "Plan", "Plan steps": "Pasos del plan", "Plugin": "Plugin", "Plugin roster and trust": "Registro de plugins y confianza", "Plugins": "Plugins", "Plugins by origin": "Plugins por origen", "Plugins by trust class": "Plugins por clase de confianza", "Policy": "Política", "Policy decisions": "Decisiones de política", "Positions & actions": "Posiciones y acciones", "Posture": "Postura", "Price action": "Acción del precio", "Proposal": "Propuesta", "Proposal status": "Estado de la propuesta", "Proposed, not admitted": "Propuesto, no admitido", "Pulse": "Pulso", "Quarantine feed": "Entrada de cuarentena", "Ratio": "Relación", "Read live from the registry and trust ledger every cycle.": "Leído en vivo del registro y del libro de confianza en cada ciclo.", "Recent episodes": "Episodios recientes", "Reclaim candidates": "Candidatos a recuperación", "Reclaimable": "Recuperable", "References & follow-ups": "Referencias y seguimientos", "References (entities)": "Referencias (entidades)", "Registry": "Registro", "Registry delta": "Delta del registro", "Registry version": "Versión del registro", "Regressions": "Regresiones", "Rejected": "Rechazado", "Representative observations, capped for quick scanning.": "Observaciones representativas, limitadas para lectura rápida.", "Requirements": "Requisitos", "Research lens": "Vista de investigación", "Residual": "Residuo", "Runtime evidence": "Evidencia en ejecución", "Sampled history per channel, newest on the right": "Historial muestreado por canal, el más reciente a la derecha", "Segment": "Segmento", "Segments by status": "Segmentos por estado", "Selectable": "Seleccionable", "Selection delta": "Delta de selección", "Self-acquired": "Autoadquirido", "Self-acquired plugins that are registered but unselectable or never once used.": "Plugins autoadquiridos que están registrados pero no son seleccionables, o nunca se han usado.", "Sentiment lens": "Vista de sentimiento", "Series": "Serie", "Session analysis": "Análisis de sesión", "Signal strength": "Fuerza de la señal", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "Señales de que algo creció mal o fue retenido. Se muestran independientemente de la pestaña abierta.", "Skipped slots": "Ranuras omitidas", "State": "Estado", "Storyline and signal strength before drilling into positions and actions.": "Narrativa y fuerza de la señal antes de entrar en posiciones y acciones.", "Streaming": "Transmisión", "Suggested next steps": "Próximos pasos sugeridos", "The line of investigation and where the open questions concentrate.": "La línea de investigación y dónde se concentran las preguntas abiertas.", "The narrative arc and how strongly themes are trending.": "El arco narrativo y con qué fuerza se mueven los temas.", "The world model asked for these capabilities and nothing took them up.": "El modelo del mundo pidió estas capacidades y nada las asumió.", "Theme intensity": "Intensidad temática", "Themes": "Temas", "This board reports how the framework changes itself. Nothing has been recorded yet.": "Este panel informa de cómo el framework se modifica a sí mismo. Todavía no se ha registrado nada.", "To": "Hasta", "Tool": "Herramienta", "Tool-name conflicts": "Conflictos de nombres de herramientas", "Tools": "Herramientas", "Transport": "Transporte", "Transport, provenance and channel counts": "Transporte, procedencia y número de canales", "Trust": "Confianza", "Trust accrual": "Acumulación de confianza", "Trust class": "Clase de confianza", "Unselectable reclamation": "Recuperación no seleccionable", "VERIFIED": "Verificado", "Verified": "Verificado", "Verified by": "Verificado por", "Voices & concerns": "Voces e inquietudes", "Watchlist": "Lista de seguimiento", "What changed in the environment, and what the framework did about it.": "Qué cambió en el entorno y qué hizo el framework al respecto.", "Which plugin owns which tool, and which capability that tool provides.": "Qué plugin posee qué herramienta y qué capacidad proporciona esa herramienta.", "Who/what is in the conversation, and the concerns still open.": "Quién/qué está en la conversación y las inquietudes aún abiertas.", "Why": "Por qué", "Why not admitted": "Motivo de no admisión", "Why this page is empty": "Por qué esta página está vacía", "World-model driver": "Controlador del modelo del mundo", "Writable": "Escribible", "aborted": "Abortado", "accruing": "Acumulando", "active": "Activo", "appeared": "Apareció", "armed": "Armado", "assess_compatibility": "Evaluar compatibilidad", "built_in": "Integrado", "capability_expand": "Ampliar capacidad", "committed": "Concluido", "conformance": "Conformidad", "declared_fitness": "Aptitud declarada", "disable": "Desactivar", "disposed": "Liberado", "environment_probe": "Sonda de entorno", "failed": "Fallido", "frozen": "Congelado", "gone": "Desapareció", "idle": "Inactivo", "install": "Instalar", "loading": "Cargando", "manual": "Manual", "moved": "Se movió", "new_unproven": "Nuevo, no probado", "no": "No", "no_evidence": "Sin evidencia", "none": "Ninguno", "not_admitted": "No admitido", "not_applicable": "No aplicable", "observe_only": "Solo observar", "observed_effect": "Efecto observado", "open": "Abierto", "pending": "Pendiente", "reload": "Recargar", "remove": "Eliminar", "reopened": "Reabierto", "resolved": "Resuelto", "rollback": "Revertir", "runtime": "Tiempo de ejecución", "self_acquired": "Autoadquirido", "still_open": "Aún abierto", "trusted": "De confianza", "unknown": "Desconocido", "unknown_tool": "Herramienta desconocida", "unloading": "Descargando", "unscheduled": "No planificado", "unverifiable": "No verificable", "unverified": "No verificado", "waiting": "En espera", "watching": "Vigilando", "wired": "Conectado", "world_model": "Modelo del mundo", "yes": "Sí"}, + ar: {"> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **انحدار: فجوة أُغلقت عادت للظهور.** تطوّر بدا ناجحًا لم يصمد. هذا هو الاكتشاف الوحيد في هذه اللوحة الذي يستدعي انتباهًا فوريًا.", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **لقطة فقط.** لا يوجد بعد تاريخ سببي لإعادة بنائه، لذا فالخط الزمني غائب وليس فارغًا. السبب مبيَّن في صف `قرارات السياسة` تحت تغطية المسار؛ اللقطة الحيّة وجدول التغطية غير متأثرين.", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **بعض الإضافات مُجمَّدة بسبب خلل داخلي.** الإضافة المُجمَّدة لا تزال تُبلِّغ `DRAFT`، وبُعد الثقة يقوم بالتقييم فقط، لذا تبقى قابلة للاختيار إلا إذا أُلغي تسجيلها أيضًا — راجع عمود `قابل للاختيار`.", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **مستوى التحقق: L2 (الملاءمة المُعلنة).** سحب الرصد يعني أن مرشّحًا *أعلن* أنه يوفّر القدرة، لا أن القدرة رُصدت وهي تعمل. التحقق من الأثر (L3) غير موصول، لذا لا ينبغي قراءة أي إغلاق في هذه اللوحة كأمر مُثبَت.", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> يجب أن تكتمل دورة مراقبة واحدة قبل ظهور أي محتوى. إذا استمر ذلك، تحقّق من تمكين المُجدول وأن مراقبة `framework-evolution` مُسلّحة وغير مكتومة.", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> تُكتب الحلقة عندما يؤدي رصد للبيئة إلى قرار بشأن قدرة. لم يُسجَّل أي منها، وهذا يعني إمّا نظامًا هادئًا أو مسارًا يتوقف قبل ذلك — تبويب **المسار** يحدّد الجزء الذي يتوقف عنده وما الذي يزيل التعطيل.", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> لا شيء يستعيدها تلقائيًا. كل واحدة تحتجز اسم أداة وتظهر في قائمة القدرات دون أن تكون قابلة للاختيار، فينمو السجل في اتجاه لا يمكن لأي مطلب استخدامه.", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> لم تدخل هذه المقترحات أي مسار، لذا لا تظهر في أي سجل قرار أو رصد. قبولها خيار في الإعدادات.", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "نسبة أقل من 1.0 تعني أن حلقة أخذ العينات لا تحافظ على وتيرتها المعلنة.", "Acquisition authority": "سلطة الاكتساب", "Acquisition lifecycle": "دورة حياة الاكتساب", "Action": "الإجراء", "After": "بعد", "An unverified declaration has its writable channels demoted to read-only.": "الإعلان غير المُتحقَّق منه تُخفَّض قنواته القابلة للكتابة إلى القراءة فقط.", "Approval": "الموافقة", "Autonomous governance": "الحكم الذاتي", "Autonomy": "الاستقلالية", "Before": "قبل", "CANDIDATE": "مرشّح", "Calibrated at": "تاريخ المعايرة", "Calibration health": "سلامة المعايرة", "Calls": "الاستدعاءات", "Calls (decisions)": "التوصيات (القرارات)", "Candlestick": "الشموع", "Capability": "القدرة", "Capability adaptation": "تكييف القدرات", "Capability observations": "رصد القدرات", "Capability ownership": "ملكية القدرات", "Capability topology": "طوبولوجيا القدرات", "Change": "التغيير", "Channel": "القناة", "Channels": "القنوات", "Channels that have never been calibrated or whose calibration has expired are shown first.": "تظهر أولاً القنوات التي لم تُعاير قط أو التي انتهت صلاحية معايرتها.", "Command": "الأمر", "Commanded versus observed, best tracking first": "المأمور مقابل المرصود، الأفضل تتبعاً أولاً", "Composition": "التركيب", "Concerns (open questions)": "المخاوف (أسئلة مفتوحة)", "Confidence": "الثقة", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "محسوب على كل قناة مرسومة. \"قريب\" تعني داخل 5% من حد معلن.", "Cycles run": "الدورات المنفَّذة", "DRAFT": "مسوّدة", "Days since": "الأيام المنقضية", "Decision": "القرار", "Decisions read as calls; action items as the execution checklist.": "القرارات تُقرأ كتوصيات؛ والإجراءات كقائمة تنفيذ.", "Declared Hz": "الهرتز المعلن", "Desk brief": "موجز المكتب", "Device": "الجهاز", "Dropped samples": "العينات المفقودة", "Each row names one blocked segment and the change that would unblock it.": "كل صف يحدّد جزءًا معطَّلًا والتغيير الذي يزيل التعطيل.", "Effect verification (L3)": "التحقق من الأثر (L3)", "Entities as references, and recommended next prompts to advance the work.": "الكيانات كمراجع، والمطالبات التالية الموصى بها لدفع العمل.", "Entities in play and the open risks still to resolve.": "الكيانات المعنية والمخاطر المفتوحة التي لم تُحل.", "Envelope, rate, staleness and quality observations · newest first": "رصدات المغلف والمعدل والتقادم والجودة · الأحدث أولاً", "Environment": "البيئة", "Environment to framework": "من البيئة إلى الإطار", "Environment, selected plugin tools, and orchestration order.": "البيئة والأدوات المختارة وترتيب التنسيق.", "Error rate": "معدل الأخطاء", "Events paced out": "الأحداث المُقيَّدة", "Ever used": "استُخدم سابقًا", "Evidence": "الدليل", "Evidence admission": "قبول الأدلة", "Evolution": "التطور", "Evolution timeline": "الخط الزمني للتطور", "Executable": "قابل للتنفيذ", "Execution checklist": "قائمة التنفيذ", "Extracted from this session's tool/file output (not model-generated).": "مستخرج من مخرجات الأدوات/الملفات في هذه الجلسة (ليس من إنشاء النموذج).", "Failures": "الأعطال", "Fiber": "الخيط", "Fiber state changes since the previous cycle, including load retries.": "تغييرات حالة الـ fiber منذ الدورة السابقة، بما في ذلك محاولات التحميل.", "Finance lens": "منظور مالي", "Follow-ups": "المتابعات", "Framework change": "تغيير الإطار", "Framework changes as they happened, from runtime probes.": "تغييرات الإطار لحظة حدوثها، من مجسّات وقت التشغيل.", "Framework evolution": "تطور الإطار", "Framework size and how much of the evolution pipeline shows runtime evidence.": "حجم الإطار ومقدار ما يُظهره مسار التطور من أدلة وقت التشغيل.", "From": "من", "Frozen plugins": "الإضافات المُجمَّدة", "Gap closure": "إغلاق الفجوة", "Halt": "إيقاف", "How closures are verified": "كيف يُتحقَّق من الإغلاقات", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "ما مقدار ما نمّاه الإطار بنفسه، وما مقدار المسار الذي يُظهر أدلة وقت التشغيل.", "How often each window sat inside, near, or outside its declared limits": "عدد المرات التي كانت فيها كل نافذة داخل حدودها المعلنة أو قريبة منها أو خارجها", "Inquiry brief": "موجز الاستقصاء", "Insights carded as evidence, capped for fast review.": "الرؤى معروضة كأدلة، ومحدودة العدد للمراجعة السريعة.", "Instruments & counterparties": "الأدوات والأطراف المقابلة", "Kept": "المحتفظ به", "Latest capability decision": "أحدث قرار للقدرات", "Lifecycle records": "سجلات دورة الحياة", "Lifecycle timeline": "الخط الزمني لدورة الحياة", "Lifecycle transitions": "انتقالات دورة الحياة", "Line of inquiry": "خط الاستقصاء", "Live activity": "النشاط المباشر", "Location": "الموقع", "Loop phase": "مرحلة الحلقة", "Mean of each downsample window. Declared limits are listed per channel below.": "متوسط كل نافذة تخفيض للعينات. الحدود المعلنة مدرجة لكل قناة أدناه.", "Model's reasoning": "استدلال النموذج", "Mutation": "التغيير", "Narrative": "السرد", "Narrative pulse": "نبض السرد", "Needs attention": "يستدعي الانتباه", "Next recal due": "موعد إعادة المعايرة", "Next step": "الخطوة التالية", "No causal history yet": "لا يوجد تاريخ سببي بعد", "Normalized error": "الخطأ المعياري", "Normalized error is the residual as a share of the channel's declared span.": "الخطأ المعياري هو المتبقي كنسبة من المدى المعلن للقناة.", "Not yet observed": "لم يُرصد بعد", "Nothing has driven a framework change, so there is no episode to narrate.": "لم يدفع أي شيء بعد إلى تغيير في الإطار، لذا لا توجد حلقة لسردها.", "OHLC extracted from captured session market data.": "OHLC مستخرج من بيانات السوق المسجلة في الجلسة.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "قائمة الرصد وحالة المقترحات وقرارات السياسة ونتائج دورة الحياة.", "Observations": "الرصدات", "Observed Hz": "الهرتز المرصود", "Observed rate against declared rate": "المعدل المرصود مقابل المعدل المعلن", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "مساحة أسماء عالمية واحدة، تُحكَّم بأسبقية التسجيل. يُسجَّل المتنافس ولا يُهمَل بصمت.", "Open": "مفتوح", "Open risks": "المخاطر المفتوحة", "Open/high/low/close from captured tool output.": "الافتتاح/الأعلى/الأدنى/الإغلاق من مخرجات الأدوات المسجلة.", "Origin": "المصدر", "Outcome": "النتيجة", "PRODUCTION": "إنتاج", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "لكل حلقة: المُحفِّز والقرار والتغيير وما إذا أُغلقت الفجوة.", "Per-channel calibration state, freshness, and residual correction": "حالة المعايرة وحداثتها وتصحيح المتبقي لكل قناة", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "أدلة وقت التشغيل لكل مقطع. وجود وحدة لا يعني أن شيئًا يستدعيها.", "Pipeline": "المسار", "Pipeline evidence": "أدلة المسار", "Pipeline reachability": "إمكانية الوصول إلى المسار", "Plan": "الخطة", "Plan steps": "خطوات الخطة", "Plugin": "الملحق", "Plugin roster and trust": "قائمة الملحقات والثقة", "Plugins": "الملحقات", "Plugins by origin": "الإضافات حسب المصدر", "Plugins by trust class": "الإضافات حسب فئة الثقة", "Policy": "السياسة", "Policy decisions": "قرارات السياسة", "Positions & actions": "المراكز والإجراءات", "Posture": "الوضع", "Price action": "حركة السعر", "Proposal": "المقترح", "Proposal status": "حالة المقترح", "Proposed, not admitted": "مُقترح وغير مقبول", "Pulse": "النبض", "Quarantine feed": "تغذية الحجر", "Ratio": "النسبة", "Read live from the registry and trust ledger every cycle.": "يُقرأ مباشرة من السجل ودفتر الثقة في كل دورة.", "Recent episodes": "الحلقات الأخيرة", "Reclaim candidates": "مرشّحو الاسترجاع", "Reclaimable": "قابل للاسترجاع", "References & follow-ups": "المراجع والمتابعات", "References (entities)": "المراجع (الكيانات)", "Registry": "السجل", "Registry delta": "فرق السجل", "Registry version": "إصدار السجل", "Regressions": "الانحدارات", "Rejected": "المرفوض", "Representative observations, capped for quick scanning.": "رصدات تمثيلية، محدودة العدد للقراءة السريعة.", "Requirements": "المتطلبات", "Research lens": "منظور بحثي", "Residual": "المتبقي", "Runtime evidence": "دليل وقت التشغيل", "Sampled history per channel, newest on the right": "سجل العينات لكل قناة، الأحدث على اليمين", "Segment": "المقطع", "Segments by status": "الأجزاء حسب الحالة", "Selectable": "قابل للاختيار", "Selection delta": "فرق الاختيار", "Self-acquired": "مُكتسَب ذاتيًا", "Self-acquired plugins that are registered but unselectable or never once used.": "إضافات مُكتسَبة ذاتيًا مُسجَّلة لكنها غير قابلة للاختيار أو لم تُستخدم قطّ.", "Sentiment lens": "منظور المشاعر", "Series": "السلسلة", "Session analysis": "تحليل الجلسة", "Signal strength": "قوة الإشارة", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "إشارات على أن شيئًا نما بشكل خاطئ أو تم حجبه. تظهر أيًا كان التبويب المفتوح.", "Skipped slots": "الفتحات المتخطاة", "State": "الحالة", "Storyline and signal strength before drilling into positions and actions.": "السرد وقوة الإشارة قبل التوسع في المراكز والإجراءات.", "Streaming": "بث", "Suggested next steps": "الخطوات التالية المقترحة", "The line of investigation and where the open questions concentrate.": "خط البحث وأين تتركز الأسئلة المفتوحة.", "The narrative arc and how strongly themes are trending.": "قوس السرد ومدى قوة اتجاه الموضوعات.", "The world model asked for these capabilities and nothing took them up.": "طلب نموذج العالم هذه القدرات ولم يتبنّها شيء.", "Theme intensity": "شدة الموضوعات", "Themes": "الموضوعات", "This board reports how the framework changes itself. Nothing has been recorded yet.": "تُبلِّغ هذه اللوحة عن كيفية تغيير الإطار لنفسه. لم يُسجَّل أي شيء بعد.", "To": "إلى", "Tool": "الأداة", "Tool-name conflicts": "تعارضات أسماء الأدوات", "Tools": "الأدوات", "Transport": "النقل", "Transport, provenance and channel counts": "النقل والمنشأ وعدد القنوات", "Trust": "الثقة", "Trust accrual": "تراكم الثقة", "Trust class": "فئة الثقة", "Unselectable reclamation": "استرجاع غير القابل للاختيار", "VERIFIED": "مُتحقَّق", "Verified": "مُتحقَّق", "Verified by": "تم التحقق بواسطة", "Voices & concerns": "الأصوات والمخاوف", "Watchlist": "قائمة المتابعة", "What changed in the environment, and what the framework did about it.": "ما تغيّر في البيئة، وما فعله الإطار حيال ذلك.", "Which plugin owns which tool, and which capability that tool provides.": "أي ملحق يملك أي أداة، وأي قدرة توفرها تلك الأداة.", "Who/what is in the conversation, and the concerns still open.": "من/ما هو في المحادثة، والمخاوف التي لا تزال مفتوحة.", "Why": "السبب", "Why not admitted": "سبب عدم القبول", "Why this page is empty": "لماذا هذه الصفحة فارغة", "World-model driver": "مُشغِّل نموذج العالم", "Writable": "قابل للكتابة", "aborted": "مُلغى", "accruing": "قيد التراكم", "active": "نشط", "appeared": "ظهر", "armed": "مُسلّح", "assess_compatibility": "تقييم التوافق", "built_in": "مدمج", "capability_expand": "توسيع القدرة", "committed": "مُنجَز", "conformance": "المطابقة", "declared_fitness": "الملاءمة المُعلنة", "disable": "تعطيل", "disposed": "تم التخلص منه", "environment_probe": "مِجَس البيئة", "failed": "فشل", "frozen": "مُجمَّد", "gone": "اختفى", "idle": "خامل", "install": "تثبيت", "loading": "قيد التحميل", "manual": "يدوي", "moved": "انتقل", "new_unproven": "جديد وغير مُثبَت", "no": "لا", "no_evidence": "لا يوجد دليل", "none": "لا شيء", "not_admitted": "غير مقبول", "not_applicable": "غير منطبق", "observe_only": "المراقبة فقط", "observed_effect": "الأثر المرصود", "open": "مفتوح", "pending": "معلّق", "reload": "إعادة تحميل", "remove": "إزالة", "reopened": "أُعيد فتحه", "resolved": "تم الحل", "rollback": "تراجع", "runtime": "وقت التشغيل", "self_acquired": "مُكتسَب ذاتيًا", "still_open": "لا يزال مفتوحًا", "trusted": "موثوق", "unknown": "غير معروف", "unknown_tool": "أداة غير معروفة", "unloading": "قيد الإلغاء", "unscheduled": "غير مُجدول", "unverifiable": "غير قابل للتحقق", "unverified": "غير مُتحقَّق", "waiting": "في الانتظار", "watching": "يراقب", "wired": "موصول", "world_model": "نموذج العالم", "yes": "نعم"}, + ru: {"> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **Регрессия: закрытый пробел возобновился.** Эволюция, казавшаяся успешной, не удержалась. Это единственный вывод на этой панели, требующий немедленного внимания.", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **Только снимок.** Причинной истории для восстановления пока нет, поэтому хронология отсутствует, а не пуста. Причина указана в строке `Решения политики` под покрытием конвейера; снимок и таблица покрытия не затронуты.", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **Некоторые плагины заморожены из-за внутреннего дефекта.** Замороженный плагин по-прежнему сообщает `DRAFT`, а измерение доверия только *оценивает*, поэтому он остаётся выбираемым, пока не будет также снят с регистрации — см. столбец `Выбираемо`.", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **Уровень проверки: L2 (заявленная пригодность).** Снятое наблюдение означает, что кандидат *заявил* о предоставлении возможности, а не что возможность наблюдалась в работе. Проверка эффекта (L3) не подключена, поэтому ни одно закрытие на этой панели не следует считать доказанным.", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> Прежде чем появятся данные, должен завершиться хотя бы один цикл наблюдения. Если это сохраняется, проверьте, включён ли планировщик и что наблюдение `framework-evolution` активно и не отключено.", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> Эпизод записывается, когда наблюдение окружения приводит к решению о возможности. Ни одного не зафиксировано: либо система спокойна, либо конвейер останавливается раньше — вкладка **Конвейер** называет сегмент остановки и то, что его разблокирует.", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> Ничто не утилизирует их автоматически. Каждый занимает имя инструмента и присутствует в списке возможностей, не будучи выбираемым: реестр растёт в направлении, непригодном ни для одного требования.", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> Эти предложения не вошли ни в один конвейер, поэтому не отражены ни в одной записи решения или наблюдения. Их приём — вопрос конфигурации.", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "Отношение ниже 1,0 означает, что цикл выборки не выдерживает объявленный ритм.", "Acquisition authority": "Право на получение", "Acquisition lifecycle": "Жизненный цикл получения", "Action": "Действие", "After": "После", "An unverified declaration has its writable channels demoted to read-only.": "У непроверенного объявления записываемые каналы понижаются до только чтения.", "Approval": "Согласование", "Autonomous governance": "Автономное управление", "Autonomy": "Автономность", "Before": "До", "CANDIDATE": "Кандидат", "Calibrated at": "Калиброван", "Calibration health": "Состояние калибровки", "Calls": "Вызовы", "Calls (decisions)": "Рекомендации (решения)", "Candlestick": "Свечи", "Capability": "Возможность", "Capability adaptation": "Адаптация возможностей", "Capability observations": "Наблюдения возможностей", "Capability ownership": "Владение возможностями", "Capability topology": "Топология возможностей", "Change": "Изменение", "Channel": "Канал", "Channels": "Каналы", "Channels that have never been calibrated or whose calibration has expired are shown first.": "Каналы, которые никогда не калибровались или чья калибровка истекла, показаны первыми.", "Command": "Команда", "Commanded versus observed, best tracking first": "Заданное против наблюдаемого, лучшее отслеживание первым", "Composition": "Состав", "Concerns (open questions)": "Опасения (открытые вопросы)", "Confidence": "Уверенность", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "Подсчитано по всем отображаемым каналам. «У границы» — в пределах 5% от объявленного предела.", "Cycles run": "Выполнено циклов", "DRAFT": "Черновик", "Days since": "Дней с тех пор", "Decision": "Решение", "Decisions read as calls; action items as the execution checklist.": "Решения читаются как рекомендации; действия — как чек-лист исполнения.", "Declared Hz": "Объявл. Гц", "Desk brief": "Сводка деска", "Device": "Устройство", "Dropped samples": "Отброшенные образцы", "Each row names one blocked segment and the change that would unblock it.": "Каждая строка называет заблокированный сегмент и изменение, которое его разблокирует.", "Effect verification (L3)": "Проверка эффекта (L3)", "Entities as references, and recommended next prompts to advance the work.": "Сущности как ссылки и рекомендуемые следующие запросы.", "Entities in play and the open risks still to resolve.": "Задействованные сущности и нерешённые риски.", "Envelope, rate, staleness and quality observations · newest first": "Наблюдения по огибающей, частоте, устареванию и качеству · сначала новые", "Environment": "Окружение", "Environment to framework": "От окружения к фреймворку", "Environment, selected plugin tools, and orchestration order.": "Окружение, выбранные инструменты плагинов и порядок оркестрации.", "Error rate": "Частота ошибок", "Events paced out": "Событий подавлено", "Ever used": "Использовался", "Evidence": "Обоснование", "Evidence admission": "Приём данных", "Evolution": "Эволюция", "Evolution timeline": "Хронология эволюции", "Executable": "Исполнимо", "Execution checklist": "Чек-лист исполнения", "Extracted from this session's tool/file output (not model-generated).": "Извлечено из вывода инструментов/файлов этой сессии (не сгенерировано моделью).", "Failures": "Сбои", "Fiber": "Файбер", "Fiber state changes since the previous cycle, including load retries.": "Изменения состояния fiber с предыдущего цикла, включая повторные загрузки.", "Finance lens": "Финансовый ракурс", "Follow-ups": "Продолжения", "Framework change": "Изменение фреймворка", "Framework changes as they happened, from runtime probes.": "Изменения фреймворка в момент их появления, от рантайм-зондов.", "Framework evolution": "Эволюция фреймворка", "Framework size and how much of the evolution pipeline shows runtime evidence.": "Размер фреймворка и какая часть конвейера эволюции показывает свидетельства времени выполнения.", "From": "Из", "Frozen plugins": "Замороженные плагины", "Gap closure": "Закрытие пробела", "Halt": "Останов", "How closures are verified": "Как проверяются закрытия", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "Какую часть фреймворка он вырастил сам и какая часть конвейера показывает данные времени выполнения.", "How often each window sat inside, near, or outside its declared limits": "Как часто каждое окно было внутри, у границы или вне объявленных пределов", "Inquiry brief": "Сводка исследования", "Insights carded as evidence, capped for fast review.": "Инсайты как карточки-обоснования, ограничены для быстрого просмотра.", "Instruments & counterparties": "Инструменты и контрагенты", "Kept": "Оставлен", "Latest capability decision": "Последнее решение о возможностях", "Lifecycle records": "Записи жизненного цикла", "Lifecycle timeline": "Хронология жизненного цикла", "Lifecycle transitions": "Переходы жизненного цикла", "Line of inquiry": "Линия исследования", "Live activity": "Текущая активность", "Location": "Расположение", "Loop phase": "Фаза цикла", "Mean of each downsample window. Declared limits are listed per channel below.": "Среднее по каждому окну прореживания. Объявленные пределы указаны по каналам ниже.", "Model's reasoning": "Обоснование модели", "Mutation": "Изменение", "Narrative": "Сюжет", "Narrative pulse": "Нарративный пульс", "Needs attention": "Требует внимания", "Next recal due": "Следующая рекалибровка", "Next step": "Следующий шаг", "No causal history yet": "Причинной истории пока нет", "Normalized error": "Нормированная ошибка", "Normalized error is the residual as a share of the channel's declared span.": "Нормированная ошибка — остаток как доля объявленного диапазона канала.", "Not yet observed": "Ещё не наблюдалось", "Nothing has driven a framework change, so there is no episode to narrate.": "Ничто пока не вызвало изменения фреймворка, поэтому рассказывать не о чем.", "OHLC extracted from captured session market data.": "OHLC извлечён из рыночных данных, записанных в сессии.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "Очередь наблюдений, состояние предложений, решения политики и итоги жизненного цикла.", "Observations": "Наблюдения", "Observed Hz": "Наблюд. Гц", "Observed rate against declared rate": "Наблюдаемая частота против объявленной", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "Единое глобальное пространство имён, арбитраж по первому пришедшему. Претендент записывается, а не отбрасывается молча.", "Open": "Открыт", "Open risks": "Открытые риски", "Open/high/low/close from captured tool output.": "Открытие/максимум/минимум/закрытие из записанного вывода инструментов.", "Origin": "Источник", "Outcome": "Результат", "PRODUCTION": "Продакшн", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "По эпизодам: триггер, решение, изменение и закрылся ли пробел.", "Per-channel calibration state, freshness, and residual correction": "Состояние калибровки, актуальность и остаточная поправка по каналам", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "Свидетельства времени выполнения по сегментам. Наличие модуля не доказывает, что его кто-то вызывает.", "Pipeline": "Конвейер", "Pipeline evidence": "Свидетельства конвейера", "Pipeline reachability": "Достижимость конвейера", "Plan": "План", "Plan steps": "Шаги плана", "Plugin": "Плагин", "Plugin roster and trust": "Реестр плагинов и доверие", "Plugins": "Плагины", "Plugins by origin": "Плагины по происхождению", "Plugins by trust class": "Плагины по классу доверия", "Policy": "Политика", "Policy decisions": "Решения политики", "Positions & actions": "Позиции и действия", "Posture": "Состояние", "Price action": "Ценовое движение", "Proposal": "Предложение", "Proposal status": "Статус предложения", "Proposed, not admitted": "Предложено, не принято", "Pulse": "Пульс", "Quarantine feed": "Поток карантина", "Ratio": "Отношение", "Read live from the registry and trust ledger every cycle.": "Читается напрямую из реестра и журнала доверия каждый цикл.", "Recent episodes": "Недавние эпизоды", "Reclaim candidates": "Кандидаты на утилизацию", "Reclaimable": "Утилизируемо", "References & follow-ups": "Ссылки и продолжения", "References (entities)": "Ссылки (сущности)", "Registry": "Реестр", "Registry delta": "Изменение реестра", "Registry version": "Версия реестра", "Regressions": "Регрессии", "Rejected": "Отклонён", "Representative observations, capped for quick scanning.": "Показательные наблюдения, ограничены для быстрого просмотра.", "Requirements": "Требования", "Research lens": "Исследовательский ракурс", "Residual": "Остаток", "Runtime evidence": "Свидетельство времени выполнения", "Sampled history per channel, newest on the right": "История выборок по каналам, самое новое справа", "Segment": "Сегмент", "Segments by status": "Сегменты по статусу", "Selectable": "Выбираемый", "Selection delta": "Изменение выбора", "Self-acquired": "Самостоятельно получено", "Self-acquired plugins that are registered but unselectable or never once used.": "Самостоятельно полученные плагины, которые зарегистрированы, но невыбираемы или ни разу не использовались.", "Sentiment lens": "Ракурс тональности", "Series": "Серия", "Session analysis": "Анализ сессии", "Signal strength": "Сила сигнала", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "Признаки того, что что-то выросло неверно или было задержано. Показываются независимо от открытой вкладки.", "Skipped slots": "Пропущенные слоты", "State": "Состояние", "Storyline and signal strength before drilling into positions and actions.": "Сюжет и сила сигнала до перехода к позициям и действиям.", "Streaming": "Потоковая передача", "Suggested next steps": "Рекомендуемые следующие шаги", "The line of investigation and where the open questions concentrate.": "Линия исследования и где сосредоточены открытые вопросы.", "The narrative arc and how strongly themes are trending.": "Нарративная дуга и насколько сильно растут темы.", "The world model asked for these capabilities and nothing took them up.": "Модель мира запросила эти возможности, и никто их не принял.", "Theme intensity": "Интенсивность тем", "Themes": "Темы", "This board reports how the framework changes itself. Nothing has been recorded yet.": "Эта панель сообщает, как фреймворк изменяет сам себя. Пока ничего не записано.", "To": "В", "Tool": "Инструмент", "Tool-name conflicts": "Конфликты имён инструментов", "Tools": "Инструменты", "Transport": "Транспорт", "Transport, provenance and channel counts": "Транспорт, происхождение и число каналов", "Trust": "Доверие", "Trust accrual": "Накопление доверия", "Trust class": "Класс доверия", "Unselectable reclamation": "Утилизация невыбираемого", "VERIFIED": "Проверено", "Verified": "Проверено", "Verified by": "Подтверждено", "Voices & concerns": "Голоса и опасения", "Watchlist": "Список наблюдения", "What changed in the environment, and what the framework did about it.": "Что изменилось в окружении и что фреймворк с этим сделал.", "Which plugin owns which tool, and which capability that tool provides.": "Какой плагин владеет каким инструментом и какую возможность этот инструмент предоставляет.", "Who/what is in the conversation, and the concerns still open.": "Кто/что в разговоре и какие опасения остаются.", "Why": "Почему", "Why not admitted": "Причина отклонения", "Why this page is empty": "Почему эта страница пуста", "World-model driver": "Драйвер модели мира", "Writable": "Записываемый", "aborted": "Прервано", "accruing": "Накапливается", "active": "Активно", "appeared": "Появился", "armed": "Активно", "assess_compatibility": "Оценка совместимости", "built_in": "Встроенный", "capability_expand": "Расширение возможностей", "committed": "Завершено", "conformance": "Соответствие", "declared_fitness": "Заявленная пригодность", "disable": "Отключение", "disposed": "Освобождено", "environment_probe": "Зонд окружения", "failed": "Сбой", "frozen": "Заморожено", "gone": "Исчез", "idle": "Простой", "install": "Установка", "loading": "Загрузка", "manual": "Вручную", "moved": "Перешёл", "new_unproven": "Новое, непроверенное", "no": "Нет", "no_evidence": "Нет данных", "none": "Нет", "not_admitted": "Не принято", "not_applicable": "Неприменимо", "observe_only": "Только наблюдение", "observed_effect": "Наблюдаемый эффект", "open": "Открыто", "pending": "Ожидает", "reload": "Перезагрузка", "remove": "Удаление", "reopened": "Возобновлено", "resolved": "Закрыто", "rollback": "Откат", "runtime": "Среда выполнения", "self_acquired": "Самостоятельно получено", "still_open": "Всё ещё открыто", "trusted": "Доверенное", "unknown": "Неизвестно", "unknown_tool": "Неизвестный инструмент", "unloading": "Выгрузка", "unscheduled": "Не запланировано", "unverifiable": "Не проверяемо", "unverified": "Непроверенное", "waiting": "Ожидание", "watching": "Наблюдает", "wired": "Подключено", "world_model": "Модель мира", "yes": "Да"} }; Object.keys(I18N).concat(Object.keys(I18N_PATCH), Object.keys(I18N_TEMPLATES)) .filter((lang, at, all) => all.indexOf(lang) === at) @@ -1326,7 +1326,11 @@ const v = (p.value != null && p.value !== "") ? (p.i18nValue ? tx(p.value) : p.value) : "\u2014"; d.appendChild(el("div", "value", esc(v))); return d; }, - Markdown: (n) => el("div", "md prose", esc((n.props || {}).text)), + // Translated like every other text prop. It was not, so a Markdown notice + // stayed English in all six locales; interpolated text still cannot match a + // dictionary key, which is why templates keep counts in Stat and the prose + // here literal. + Markdown: (n) => el("div", "md prose", esc(tx((n.props || {}).text))), StoryPanel: (n) => { const p = n.props || {}; const d = el("div", "card story-panel"); d.appendChild(el("div", "card-title", esc(tx(p.title || "Storyline")))); d.appendChild(renderAbstract(p.text)); return d; }, diff --git a/src/leapflow/dashboard/templates/evolution.yaml b/src/leapflow/dashboard/templates/evolution.yaml new file mode 100644 index 00000000..c17d5c4a --- /dev/null +++ b/src/leapflow/dashboard/templates/evolution.yaml @@ -0,0 +1,490 @@ +# Framework self-evolution template for LeapBoard. +# +# Organised around the five questions a person actually brings to this page, not +# around the shape of the payload: +# +# Q1 is the framework growing? -> Evolution tab, self-acquired count + timeline +# Q2 is what it grew trustworthy? -> Composition tab, trust mix + verification tier +# Q3 did it grow wrong? -> alert band, above the tabs so no tab hides it +# Q4 why is it not growing? -> Pipeline tab, per-segment evidence +# Q5 is what it grew actually used? -> Composition tab, reclaim candidates +# +# Three tabs rather than one long scroll: the page had ten stacked sections, and the +# thing a reader needed was rarely the thing at the top. The alert band and the +# metric strip sit outside the tabs, because a regression must be visible whichever +# tab is open. +# +# Every section carries its own ``when``, at the *section* level rather than on the +# child. A ``when`` on the child leaves a titled section with nothing under it, and +# an empty panel under a heading reads as a load failure rather than as "nothing +# happened". +# +# Component choices here are constrained by what the shipped renderers actually do, +# which is not always what their names suggest. Verified before use: +# - Heatmap is an alias of BarChart (no 2D grid), so segment status stays a bar. +# - PieChart counts *severity*, not arbitrary labels, so distributions use BarChart. +# - FilterBar renders an empty div; there is no filtering to declare yet. +# - SuggestionChips takes plain strings and does not translate them, so advisory +# rows stay a Table, whose cells do translate. +# - FindingCard does not translate its title or summary; Card and Markdown do. +template: evolution +version: 1 +title: "Framework evolution" +domain: framework_evolution +meta: + title: "Framework evolution" + description: "Whether the framework is growing, whether what it grew is trustworthy, and where the evolution pipeline stops." +layout: + - type: Page + props: + title: "Framework evolution" + children: + # ── Empty state ─────────────────────────────────────────────────────── + # Rendered instead of a metric row of em dashes. Three states, three next + # steps, told apart by the watch rather than guessed. + - type: Section + when: empty + props: + title: "Not yet observed" + subtitle: "This board reports how the framework changes itself. Nothing has been recorded yet." + children: + - type: Card + when: empty.state + props: + title: "Why this page is empty" + kicker: "The producer reads the live registry and the decision history every cycle." + children: + - type: Markdown + props: + text: >- + > A watch has to complete one cycle before there is anything to show. + If this persists, check that the scheduler is enabled and that the + `framework-evolution` watch is armed and not muted. + # Interpolated Stats rather than a Table: ``empty`` is a mapping, and the + # Table renderer coerces its data with asArray -- a mapping becomes an empty + # list and the panel would render "No entries" while reporting no fault. + - type: Row + when: empty.state + props: + variant: meta + children: + - type: Stat + props: + label: "State" + i18nValue: true + value: "{{ empty.state }}" + - type: Stat + props: + label: "Watch" + i18nValue: true + value: "{{ empty.watch_state }}" + - type: Stat + props: + label: "Cycles run" + value: "{{ empty.run_count }}" + + # ── Alert band: Q3, outside the tabs on purpose ─────────────────────── + - type: Section + when: evolution.summary.attention + props: + title: "Needs attention" + subtitle: "Signals that something grew wrong, or was withheld. Shown regardless of the open tab." + children: + - type: Row + props: + variant: metrics + children: + - type: Stat + props: + label: "Regressions" + value: "{{ evolution.summary.regression_count }}" + - type: Stat + props: + label: "Frozen plugins" + value: "{{ evolution.summary.frozen_count }}" + - type: Stat + props: + label: "Tool-name conflicts" + value: "{{ evolution.summary.conflict_count }}" + - type: Stat + props: + label: "Proposed, not admitted" + value: "{{ evolution.summary.unadmitted_intent_count }}" + - type: Stat + props: + label: "Reclaimable" + value: "{{ evolution.summary.reclaimable_count }}" + # Literal, not interpolated. The renderer translates a Markdown text prop + # by dictionary lookup, and an interpolated string can never match a key -- + # so the counts live in the strip above and the prose here stays fixed. + - type: Markdown + when: evolution.summary.regression_count + props: + text: >- + > **Regression: a closed gap has recurred.** An evolution that looked + successful did not hold. This is the one finding on this board that + warrants immediate attention. + - type: Markdown + when: evolution.summary.frozen_count + props: + text: >- + > **Some plugins are frozen by an internal defect.** A frozen plugin still + reports `DRAFT`, and the trust dimension only *scores*, so it stays + selectable unless it is also unregistered — check the `Selectable` column. + + # ── Posture strip: always visible, outside the tabs ─────────────────── + - type: Section + when: evolution.summary + props: + title: "Posture" + subtitle: "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence." + children: + - type: Row + props: + variant: metrics + children: + # Deliberately first, and deliberately not "plugins": a framework has + # plugins on day one without having evolved at all. This is the number + # that answers "is it growing". + - type: Stat + props: + label: "Self-acquired" + value: "{{ evolution.summary.self_acquired_count }}" + - type: Stat + props: + label: "Plugins" + value: "{{ evolution.summary.active_plugins }}" + - type: Stat + props: + label: "Tools" + value: "{{ evolution.summary.tool_count }}" + - type: Stat + props: + label: "Recent episodes" + value: "{{ evolution.summary.episode_count }}" + - type: Gauge + props: + label: "Pipeline evidence" + value: "{{ evolution.summary.segments_with_evidence }}/{{ evolution.summary.segments_total }}" + - type: Markdown + when: evolution.degraded_reason + props: + text: >- + > **Snapshot only.** There is no causal history to rebuild yet, so the + timeline is absent rather than empty. Why is stated by the `Policy + decisions` row under pipeline reachability; the live snapshot and the + reachability table itself are unaffected. + + # ── Tabs ────────────────────────────────────────────────────────────── + - type: Tabs + when: evolution.summary + children: + # ═══ Tab 1 · Evolution (default) ═════════════════════════════════ + - type: Tab + props: + title: "Evolution" + children: + # The default tab must never be blank. With no causal history the other + # two tabs still have content, so an empty first pane reads as a broken + # page -- and it is the pane every visitor lands on. This says what is + # missing and which tab answers why. + - type: Section + when: evolution.degraded + props: + title: "No causal history yet" + subtitle: "Nothing has driven a framework change, so there is no episode to narrate." + children: + - type: Markdown + props: + text: >- + > An episode is written when an environment observation leads to a + capability decision. None has been recorded, which is either a quiet + system or a pipeline that stops earlier — the **Pipeline** tab names + the segment where it stops, and what would unblock it. + + - type: Section + when: evolution.summary.l2_only_closures + props: + title: "How closures are verified" + children: + - type: Markdown + props: + text: >- + > **Verification tier: L2 (declared fitness).** A retired observation means a + candidate *declared* it provides the capability, not that the capability was + observed to work. Effect verification (L3) is not wired yet, so no closure on + this board should be read as proven. + + - type: Section + when: evolution.timeline + props: + title: "Evolution timeline" + subtitle: "What changed in the environment, and what the framework did about it." + children: + - type: Timeline + props: + bind: evolution.timeline + + - type: Section + when: evolution.mutation_matrix + props: + title: "Environment to framework" + subtitle: "Per episode: the trigger, the decision, the change, and whether the gap closed." + children: + - type: Table + props: + bind: evolution.mutation_matrix + columns: + - key: driver + label: "Trigger" + - key: capability + label: "Capability" + - key: policy_action + label: "Decision" + - key: autonomy_level + label: "Autonomy" + - key: mutation_action + label: "Framework change" + - key: registry_delta + label: "Registry" + - key: lifecycle_status + label: "Acquisition lifecycle" + - key: gap_closure + label: "Gap closure" + - key: verification_tier + label: "Verified by" + + - type: Section + when: evolution.unadmitted + props: + title: "Proposed, not admitted" + subtitle: "The world model asked for these capabilities and nothing took them up." + children: + - type: Markdown + props: + text: >- + > These proposals entered no pipeline, so they appear in no decision + record and no observation. Admitting them is a configuration choice. + - type: Table + props: + bind: evolution.unadmitted + columns: + - key: capability + label: "Capability" + - key: hypothesis + label: "Model's reasoning" + - key: confidence + label: "Confidence" + - key: reason + label: "Why not admitted" + + # ═══ Tab 2 · Pipeline ════════════════════════════════════════════ + - type: Tab + props: + title: "Pipeline" + children: + - type: Section + when: evolution.reachability + props: + title: "Pipeline reachability" + subtitle: "Per-segment runtime evidence. A module existing is not evidence that anything calls it." + children: + # Distribution first, detail second: how much of the pipeline is + # wired is the question, and counting ten table rows to answer it is + # work the reader should not have to do. + - type: BarChart + when: evolution.reachability_mix + props: + title: "Segments by status" + bind: evolution.reachability_mix + - type: Table + props: + bind: evolution.reachability + columns: + - key: stage + label: "Segment" + - key: status + label: "Status" + - key: evidence + label: "Runtime evidence" + - key: next_step + label: "Next step" + + # Traces come from probes at points no store retains: a registry + # mutation, a trust transition, a teacher proposal. Kept separate from + # the episode timeline on purpose -- these answer "what just happened", + # not "why", and merging them would imply causal links never established. + - type: Section + when: evolution.trace_feed + props: + title: "Live activity" + subtitle: "Framework changes as they happened, from runtime probes." + children: + - type: Timeline + props: + bind: evolution.trace_feed + + # Recovered by diffing successive snapshots, because a fiber transition + # does not bump the registry version and so no probe can see it. The + # retry path (LOADING -> FAILED -> LOADING) is invisible anywhere else. + - type: Section + when: evolution.fiber_transitions + props: + title: "Lifecycle transitions" + subtitle: "Fiber state changes since the previous cycle, including load retries." + children: + - type: Table + props: + bind: evolution.fiber_transitions + columns: + - key: plugin_id + label: "Plugin" + - key: from + label: "From" + - key: to + label: "To" + - key: kind + label: "Change" + + - type: Section + when: evolution.summary.suggestions + props: + title: "Suggested next steps" + subtitle: "Each row names one blocked segment and the change that would unblock it." + children: + - type: Table + props: + bind: evolution.summary.suggestions + columns: + - key: label + label: "Action" + - key: detail + label: "Why" + + # ═══ Tab 3 · Composition ═════════════════════════════════════════ + - type: Tab + props: + title: "Composition" + children: + - type: Section + when: evolution.roster + props: + title: "Plugin roster and trust" + subtitle: "Read live from the registry and trust ledger every cycle." + children: + - type: BarChart + when: evolution.provenance_mix + props: + title: "Plugins by origin" + bind: evolution.provenance_mix + - type: BarChart + when: evolution.trust_mix + props: + title: "Plugins by trust class" + bind: evolution.trust_mix + # Deliberately no live counters here. This finding dedups on a + # content fingerprint, so a rendered metric that changes every tick + # would either churn a row per cycle or freeze on the page while + # looking current. Per-plugin error rates are plugin_health's subject. + - type: Table + props: + bind: evolution.roster + columns: + - key: plugin_id + label: "Plugin" + - key: provenance + label: "Origin" + - key: fiber_state + label: "Fiber" + - key: trust_level + label: "Trust" + - key: selectable + label: "Selectable" + - key: ever_used + label: "Ever used" + - key: tool_count + label: "Tools" + + # Q5. The recorded LF-10 case, nameable for the first time: a generated + # plugin blocked by the risk ceiling keeps its registration forever, + # occupying a tool name, because nothing reclaims an unselectable artifact. + - type: Section + when: evolution.reclaim_candidates + props: + title: "Reclaim candidates" + subtitle: "Self-acquired plugins that are registered but unselectable or never once used." + children: + - type: Markdown + props: + text: >- + > Nothing reclaims these automatically. Each holds a tool name and + appears in the capability list without being selectable, so the + registry grows in a direction no requirement can use. + - type: Table + props: + bind: evolution.reclaim_candidates + columns: + - key: plugin_id + label: "Plugin" + - key: trust_level + label: "Trust" + - key: selectable + label: "Selectable" + - key: ever_used + label: "Ever used" + - key: tool_count + label: "Tools" + + - type: Section + when: evolution.capability_map + props: + title: "Capability topology" + subtitle: "Which plugin owns which tool, and which capability that tool provides." + children: + # The shipped EntityGraph renderer is a badge cloud: it reads + # props.data as a flat list and shows each item's `name`. Binding it + # a nodes/edges mapping renders an empty panel and reports no fault, + # so the glanceable cloud takes the flat capability list and the + # relation is shown as a table below it. + - type: EntityGraph + when: evolution.capability_badges + props: + bind: evolution.capability_badges + - type: Table + props: + title: "Capability ownership" + bind: evolution.capability_map + # A capability decision has its own lens; a row here is the + # natural place to reach it. ``nav`` switches template, which is + # the only drill-down the client actually implements -- there is + # no filter-in-place action to declare. + row_buttons: + - label: "Decisions" + kind: nav + name: capability + params: + template: capability + columns: + - key: capability + label: "Capability" + - key: tool + label: "Tool" + - key: plugin + label: "Plugin" + + - type: Section + when: evolution.conflicts + props: + title: "Tool-name conflicts" + subtitle: "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped." + children: + - type: Table + props: + bind: evolution.conflicts + columns: + - key: tool_name + label: "Tool" + - key: kept_plugin + label: "Kept" + - key: rejected_plugin + label: "Rejected" diff --git a/src/leapflow/domain/__init__.py b/src/leapflow/domain/__init__.py index f5b3ab9a..88160bfa 100644 --- a/src/leapflow/domain/__init__.py +++ b/src/leapflow/domain/__init__.py @@ -16,6 +16,11 @@ ) from leapflow.domain.environment_fingerprint import EnvironmentFingerprint from leapflow.domain.events import SystemEvent, UIElement, UISnapshot +from leapflow.domain.evolution_intent import ( + WORLD_MODEL_INTENT, + WORLD_MODEL_ORIGIN, + EvolutionIntent, +) from leapflow.domain.platform import ( Capability, DEFAULT_DARWIN_CAPABILITIES, @@ -47,6 +52,7 @@ "CLIEventType", "CapabilityRequirement", "EffectScope", + "EvolutionIntent", "FiberState", "GapEvidence", "ImplicitFeedbackType", @@ -80,6 +86,8 @@ "TrajectoryStep", "UIElement", "UISnapshot", + "WORLD_MODEL_INTENT", + "WORLD_MODEL_ORIGIN", "action_type_from_event", "capability_from_str", ] diff --git a/src/leapflow/domain/capability_requirement.py b/src/leapflow/domain/capability_requirement.py index 1cb70c63..1ab605f5 100644 --- a/src/leapflow/domain/capability_requirement.py +++ b/src/leapflow/domain/capability_requirement.py @@ -19,6 +19,7 @@ "explicit_request", "environment_probe", "task_contract", + "world_model", ] ApprovalMode = Literal["review_required", "autonomous_allowed"] diff --git a/src/leapflow/domain/evolution_intent.py b/src/leapflow/domain/evolution_intent.py new file mode 100644 index 00000000..a05ccfd7 --- /dev/null +++ b/src/leapflow/domain/evolution_intent.py @@ -0,0 +1,258 @@ +"""The world model's evolution proposal contract. + +An ``EvolutionIntent`` is what the LLM-based world model emits when, given +privileged hindsight (goal + full trajectory + actual effects), it concludes that +LeapFlow lacks a capability it should have. It is the intended *first driver* of +capability self-evolution. + +Three properties make it safe to let a language model author these: + +* It is a **hypothesis, not an authorisation.** An intent carries no permission. + It converts to an ordinary :class:`CapabilityRequirement` and then traverses the + unchanged deterministic chain -- declared-fitness resolution, risk + classification, approval, artifact validation, trust. The world model decides + *what* to evolve and *why*; those components decide whether it is permitted and + whether it worked. +* It is **declaration-driven.** Capability, target, and risk ceiling are explicit + fields, never parsed out of prose, so no free-text inference reaches the + governed pipeline. +* It is **evidence-linked.** ``evidence_ids`` ties the intent back to the + observations and experiences that motivated it, so an intent can be audited, + replayed, and (once acted on) retired. + +The intent deliberately reuses the existing observation path rather than adding a +parallel one: :meth:`to_observation_result` renders the payload shape +``CapabilityObservationService``/``CapabilityGapDetector`` already consume, so a +world-model intent is governed by exactly the same machinery as an +``unknown_tool`` signal. +""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Mapping + +from leapflow.domain.capability_requirement import CapabilityRequirement +from leapflow.domain.plugin_proposal import RiskLevel + +#: Evidence kind carried by a world-model intent. Admit it through +#: ``CapabilityEvidenceClassifier`` to let the world model drive evolution; it is +#: intentionally absent from ``DEFAULT_ACCEPTED_EVIDENCE`` so shipped behaviour is +#: unchanged until an operator opts in. +WORLD_MODEL_INTENT = "world_model_intent" + +#: Requirement origin recorded for anything derived from an intent. +WORLD_MODEL_ORIGIN = "world_model" + +#: Risk ceiling applied to anything a model authored, unless a trusted caller +#: raises it explicitly. ``max_risk_level`` on a requirement is a *ceiling*, so a +#: larger value is more permissive -- which makes it partly an authorisation, not +#: merely a description. An intent may therefore only ever *narrow* the ceiling: +#: the effective value is the stricter of what the intent asked for and what the +#: trusted caller allows. +MODEL_AUTHORED_RISK_CEILING: RiskLevel = "read_only" + +# Ascending permissiveness, matching ``RiskLevel``. +_RISK_ORDER: tuple[str, ...] = ("read_only", "low", "medium", "high", "mutating", "external") + + +def _risk_rank(level: str) -> int: + """Rank a risk level, treating anything unknown as the most permissive. + + An unrecognised value must not read as *safe*, or a typo would silently widen + the ceiling; ranking it highest means the clamp below always rejects it. + """ + try: + return _RISK_ORDER.index(str(level)) + except ValueError: + return len(_RISK_ORDER) - 1 + + +def _stricter(left: str, right: str) -> str: + """Return whichever risk ceiling is more restrictive.""" + return left if _risk_rank(left) <= _risk_rank(right) else right + + +def _freeze(values: Any) -> tuple[str, ...]: + if not values: + return () + if isinstance(values, str): + return (values,) + return tuple(str(v) for v in values if str(v)) + + +@dataclass(frozen=True) +class EvolutionIntent: + """One world-model hypothesis that a capability is missing or broken. + + ``confidence`` is the model's own calibration and is carried through to the + requirement's metadata; it informs prioritisation and audit but must never be + read as permission -- a high-confidence intent still passes every gate. + """ + + intent_id: str + capability: str + hypothesis: str + confidence: float = 0.0 + target_affordance: str = "" + rationale: str = "" + expected_effect: str = "" + max_risk_level: RiskLevel = "read_only" + evidence_ids: tuple[str, ...] = field(default_factory=tuple) + required_platform_capabilities: tuple[str, ...] = field(default_factory=tuple) + created_at: float = 0.0 + + @classmethod + def create( + cls, + capability: str, + hypothesis: str, + *, + confidence: float = 0.0, + target_affordance: str = "", + rationale: str = "", + expected_effect: str = "", + max_risk_level: RiskLevel = "read_only", + evidence_ids: Any = None, + required_platform_capabilities: Any = None, + intent_id: str = "", + created_at: float | None = None, + ) -> "EvolutionIntent": + """Build a normalized intent. + + ``max_risk_level`` defaults to ``read_only``: a model-authored proposal + starts at the lowest ceiling and must be widened deliberately, rather than + inheriting the permissive domain default. + """ + normalized = str(capability or "").strip() + if not normalized: + raise ValueError("capability is required") + statement = str(hypothesis or "").strip() + if not statement: + raise ValueError("hypothesis is required") + return cls( + intent_id=intent_id or f"wmi-{uuid.uuid4().hex}", + capability=normalized, + hypothesis=statement, + confidence=max(0.0, min(1.0, float(confidence))), + target_affordance=str(target_affordance or ""), + rationale=str(rationale or ""), + expected_effect=str(expected_effect or ""), + max_risk_level=max_risk_level, + evidence_ids=_freeze(evidence_ids), + required_platform_capabilities=_freeze(required_platform_capabilities), + created_at=time.time() if created_at is None else float(created_at), + ) + + def effective_risk_ceiling( + self, risk_ceiling: RiskLevel = MODEL_AUTHORED_RISK_CEILING + ) -> str: + """The ceiling actually applied: the stricter of the intent's and the caller's.""" + return _stricter(str(self.max_risk_level), str(risk_ceiling)) + + def to_observation_result( + self, *, risk_ceiling: RiskLevel = MODEL_AUTHORED_RISK_CEILING + ) -> dict[str, Any]: + """Render the payload the observation/detector path already consumes. + + Using the same shape as other structured evidence is what keeps the world + model on the governed path instead of beside it. The emitted + ``max_risk_level`` is clamped by ``risk_ceiling`` so the payload cannot + widen its own permissions downstream. + """ + effective = self.effective_risk_ceiling(risk_ceiling) + payload: dict[str, Any] = { + "error_type": WORLD_MODEL_INTENT, + "origin": WORLD_MODEL_ORIGIN, + "capability": self.capability, + "evidence": self.hypothesis, + "failure_code": "world_model_capability_gap", + "recovery_hint": self.rationale or self.hypothesis, + "confidence": self.confidence, + "intent_id": self.intent_id, + "max_risk_level": effective, + "requirement_id": f"req-wm-{self.intent_id}", + "suggestions": list(self.evidence_ids), + "required_platform_capabilities": list(self.required_platform_capabilities), + "target_affordance": self.target_affordance, + "expected_effect": self.expected_effect, + } + if effective != str(self.max_risk_level): + # Keep the model's request visible for audit even though it was denied. + payload["requested_max_risk_level"] = str(self.max_risk_level) + return payload + + def to_requirement( + self, *, risk_ceiling: RiskLevel = MODEL_AUTHORED_RISK_CEILING + ) -> CapabilityRequirement: + """Convert directly to a requirement, bypassing the durable store. + + Prefer routing through ``CapabilityObservationService.observe_result`` so + the intent is persisted and auditable; this direct conversion exists for + callers that already hold the evidence trail. The risk ceiling is clamped + exactly as in :meth:`to_observation_result`. + """ + effective = self.effective_risk_ceiling(risk_ceiling) + metadata: dict[str, Any] = { + "evidence_kind": WORLD_MODEL_INTENT, + "intent_id": self.intent_id, + "confidence": self.confidence, + } + if effective != str(self.max_risk_level): + metadata["requested_max_risk_level"] = str(self.max_risk_level) + if self.target_affordance: + metadata["target_affordance"] = self.target_affordance + if self.expected_effect: + metadata["expected_effect"] = self.expected_effect + if self.evidence_ids: + metadata["evidence_ids"] = ",".join(self.evidence_ids) + return CapabilityRequirement.create( + self.capability, + WORLD_MODEL_ORIGIN, + evidence=self.hypothesis, + required_platform_capabilities=list(self.required_platform_capabilities), + max_risk_level=effective, # type: ignore[arg-type] + metadata=metadata, + requirement_id=f"req-wm-{self.intent_id}", + ) + + def to_dict(self) -> dict[str, Any]: + return { + "intent_id": self.intent_id, + "capability": self.capability, + "hypothesis": self.hypothesis, + "confidence": self.confidence, + "target_affordance": self.target_affordance, + "rationale": self.rationale, + "expected_effect": self.expected_effect, + "max_risk_level": self.max_risk_level, + "evidence_ids": list(self.evidence_ids), + "required_platform_capabilities": list(self.required_platform_capabilities), + "created_at": self.created_at, + } + + @classmethod + def from_dict(cls, payload: Mapping[str, Any]) -> "EvolutionIntent": + return cls.create( + str(payload.get("capability") or ""), + str(payload.get("hypothesis") or ""), + confidence=float(payload.get("confidence") or 0.0), + target_affordance=str(payload.get("target_affordance") or ""), + rationale=str(payload.get("rationale") or ""), + expected_effect=str(payload.get("expected_effect") or ""), + max_risk_level=payload.get("max_risk_level") or "read_only", + evidence_ids=payload.get("evidence_ids"), + required_platform_capabilities=payload.get("required_platform_capabilities"), + intent_id=str(payload.get("intent_id") or ""), + created_at=payload.get("created_at"), + ) + + +__all__ = [ + "MODEL_AUTHORED_RISK_CEILING", + "WORLD_MODEL_INTENT", + "WORLD_MODEL_ORIGIN", + "EvolutionIntent", +] diff --git a/src/leapflow/domain/evolution_trace.py b/src/leapflow/domain/evolution_trace.py new file mode 100644 index 00000000..9043ac60 --- /dev/null +++ b/src/leapflow/domain/evolution_trace.py @@ -0,0 +1,235 @@ +"""Causal types for framework self-evolution: one atomic fact, and one episode. + +Two concepts sit beside :mod:`leapflow.domain.evolution_intent`, and the pairing is +deliberate: + +* an ``EvolutionIntent`` is a *hypothesis* -- what should evolve and why, authored + by the world model before anything happens; +* an :class:`EvolutionTrace` is a *fact* -- what actually happened, recorded after + it happened; +* an :class:`EvolutionEpisode` stitches traces into one causal story, so + "the environment changed, therefore the framework changed" becomes a thing a + person can read. + +Named ``Trace`` rather than ``Signal`` because ``Signal`` already means something +else here: ``InteractionSignal`` and ``SignalSource`` are the perception layer's +vocabulary, and reusing the word for a governance fact would suggest these flow +through the same pipeline. They do not. + +The stage vocabulary is the OODA loop applied to the framework itself, with the +two ends the adaptive loop never had: what preceded the decision (the environment +or the teacher) and what came of it (trust, and whether the gap actually closed). +""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Mapping, Protocol, runtime_checkable + +# ── Episode lifecycle ──────────────────────────────────────────────────── +#: The episode reached a conclusion: the framework changed, or a decision +#: explicitly declined to change it. +COMMITTED = "committed" +#: Still in flight, or waiting for a later stage to arrive. +OPEN = "open" +#: No further trace arrived before the time-to-live elapsed. +ABORTED = "aborted" + +# ── Gap closure ────────────────────────────────────────────────────────── +#: The observation that motivated this episode was retired. +RESOLVED = "resolved" +#: A retired observation recurred. The highest-value outcome to surface: the +#: evolution looked successful and the problem came back. +REOPENED = "reopened" +#: The framework changed but the motivating observation is still open. +STILL_OPEN = "still_open" +#: Nothing to close -- no observation was linked to this episode. +NOT_APPLICABLE = "not_applicable" + +# ── Verification tier ──────────────────────────────────────────────────── +#: The artifact parses, imports and satisfies the Protocol. Says nothing about +#: whether it works. +CONFORMANCE = "conformance" +#: A candidate *declared* it provides the capability and its declared affordances +#: are present. Still says nothing about whether it works -- a structurally +#: perfect adapter aimed at the wrong thing passes this. +DECLARED_FITNESS = "declared_fitness" +#: The declared ``expected_effect`` was compared against an observed outcome. +#: The only tier that can show a capability actually delivered. +OBSERVED_EFFECT = "observed_effect" + + +class EvolutionStage(str, Enum): + """Which question a trace answers.""" + + OBSERVE = "observe" # what changed in the environment, or what did the teacher conclude + ORIENT = "orient" # which capability is therefore missing + DECIDE = "decide" # should it change, how, and why this candidate + ACT = "act" # what the framework actually did + LEARN = "learn" # how it went, and did the gap really close + + +@dataclass(frozen=True) +class EvolutionTrace: + """One atomic fact in an evolution episode. + + ``correlation`` carries the natural keys that stitch traces into one episode; + every one of them already exists upstream (``intent_id``, ``requirement_id``, + ``record_id``, ``observation_id``, ``lifecycle_proposal_id``, ``plugin_id``, + ``registry_version``), which is why no pervasive new identifier had to be + threaded through the core to make this work. + + ``detail`` is a domain-private escape hatch, exactly like ``Finding.payload``: + core code never inspects it, only the ledger and the view do. That is what + lets a later stage add fields without touching anything upstream. + """ + + stage: EvolutionStage + kind: str + ts: float = field(default_factory=time.time) + trace_id: str = field(default_factory=lambda: uuid.uuid4().hex) + correlation: Mapping[str, str] = field(default_factory=dict) + summary: str = "" + detail: Mapping[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "trace_id": self.trace_id, + "stage": self.stage.value, + "kind": self.kind, + "ts": self.ts, + "correlation": dict(self.correlation), + "summary": self.summary, + "detail": dict(self.detail), + } + + +@dataclass(frozen=True) +class EvolutionEpisode: + """One causal chain: an environment change, and the framework change it drove. + + The derived fields below are computed once when the episode is assembled so a + view can bind them directly. They are not decoration: recomputing them in a + template or a frontend is how two surfaces end up disagreeing about the same + episode. + + Fields a stage cannot yet establish stay at their empty default rather than + being guessed. ``effect_verdict`` is the clearest case: verification by + observed effect exists in the tree but nothing calls it, so an episode today + can honestly report ``verification_tier == DECLARED_FITNESS`` and must not + imply more. + """ + + episode_id: str + opened_at: float + status: str = OPEN + traces: tuple[EvolutionTrace, ...] = () + closed_at: float = 0.0 + + # ── What set this in motion ── + driver: str = "" # world_model | unknown_tool | environment_probe | manual + capability: str = "" + intent_id: str = "" + hypothesis: str = "" # the teacher's own words, when it authored this + confidence: float = 0.0 # model self-report; prioritisation only, never permission + + # ── What the framework did ── + mutation_action: str = "" # install | reload | disable | remove | rollback | none + plugin_id: str = "" + registry_before: int = -1 + registry_after: int = -1 + + # ── Governance state (two vocabularies, never merged) ── + lifecycle_status: str = "" # PENDING … VERIFIED / QUARANTINED (acquisition journey) + review_status: str = "" # draft | review | approved | rejected (human review) + policy_action: str = "" + autonomy_level: str = "" + + # ── How it turned out ── + gap_closure: str = NOT_APPLICABLE + verification_tier: str = "" + effect_verdict: str = "" + trust_at_decision: str = "" + trust_now: str = "" + outcome: str = "" + + @property + def framework_changed(self) -> bool: + """Whether the registry version actually moved. + + The hard test for "the framework really changed", as opposed to a decision + that merely intended to change it. + """ + return self.registry_after > self.registry_before >= 0 + + @property + def stages_present(self) -> frozenset[EvolutionStage]: + return frozenset(trace.stage for trace in self.traces) + + def trace_of(self, stage: EvolutionStage) -> EvolutionTrace | None: + return next((trace for trace in self.traces if trace.stage is stage), None) + + def to_dict(self) -> dict[str, Any]: + return { + "episode_id": self.episode_id, + "opened_at": self.opened_at, + "closed_at": self.closed_at, + "status": self.status, + "framework_changed": self.framework_changed, + "stages": sorted(stage.value for stage in self.stages_present), + "driver": self.driver, + "capability": self.capability, + "intent_id": self.intent_id, + "hypothesis": self.hypothesis, + "confidence": self.confidence, + "mutation_action": self.mutation_action, + "plugin_id": self.plugin_id, + "registry_before": self.registry_before, + "registry_after": self.registry_after, + "lifecycle_status": self.lifecycle_status, + "review_status": self.review_status, + "policy_action": self.policy_action, + "autonomy_level": self.autonomy_level, + "gap_closure": self.gap_closure, + "verification_tier": self.verification_tier, + "effect_verdict": self.effect_verdict, + "trust_at_decision": self.trust_at_decision, + "trust_now": self.trust_now, + "outcome": self.outcome, + "traces": [trace.to_dict() for trace in self.traces], + } + + +@runtime_checkable +class EvolutionTraceSink(Protocol): + """Consumes evolution traces. Absent by default, so emitting is a no-op. + + Declared here, in the domain layer, so a probe at a mutation point depends on + this contract and never on the ledger, the monitor subsystem, or an event bus. + The implementation is injected by the daemon; without it the framework behaves + exactly as it did before this module existed. + """ + + def record(self, trace: EvolutionTrace) -> None: + ... + + +__all__ = [ + "ABORTED", + "COMMITTED", + "CONFORMANCE", + "DECLARED_FITNESS", + "NOT_APPLICABLE", + "OBSERVED_EFFECT", + "OPEN", + "REOPENED", + "RESOLVED", + "STILL_OPEN", + "EvolutionEpisode", + "EvolutionStage", + "EvolutionTrace", + "EvolutionTraceSink", +] diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 13189e53..c265d66b 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -5378,6 +5378,51 @@ def _observe_capability_results(self, results: List[Dict[str, Any]]) -> None: for item in results: result = item.get("result") if isinstance(item, dict) else None self._observe_capability_result(result) + self._record_coevolution_outcome( + item, str(getattr(self._settings, "workspace_root", "") or "") + ) + + @staticmethod + def _record_coevolution_outcome(item: Any, workspace: str = "") -> None: + """Pair a tool outcome with the requirement its plugin was selected to serve. + + Recorded here rather than at the usage sink because this is the only place that + sees the *full result payload*, and the payload is where a tool reports what it + observably did. Without that, a successful call can only be graded + ``unverifiable`` -- so verification could refute an acquisition but never + confirm one. + + A no-op for every plugin the system did not acquire, which is almost all of + them. Bookkeeping only: never raises. + """ + if not isinstance(item, dict): + return + try: + from leapflow.evolution.observations import record_tool_outcome + from leapflow.learning.capability_effect_verifier import ( + observed_effect_from_result, + ) + from leapflow.plugins import get_registry + + tool_name = str(item.get("name") or "") + if not tool_name: + return + plugin_id = str((get_registry().tool_owners or {}).get(tool_name) or "") + if not plugin_id: + return + result = item.get("result") + ok = True + if isinstance(result, dict): + ok = bool(result.get("ok", True)) and not result.get("error") + record_tool_outcome( + plugin_id, + tool_name, + ok, + observed_effect=observed_effect_from_result(result), + workspace=workspace, + ) + except Exception: # noqa: BLE001 - observation must never affect execution + logger.debug("co-evolution outcome not recorded", exc_info=True) def _observe_capability_result(self, result: Any) -> None: """Persist an observe-only adaptive capability plan from structured gaps. @@ -5392,9 +5437,17 @@ def _observe_capability_result(self, result: Any) -> None: try: buffer = getattr(self, "_capability_observation_buffer", None) if buffer is None: - from leapflow.learning.capability_observation import CapabilityObservationBuffer + from leapflow.learning.capability_observation import ( + CapabilityEvidenceClassifier, + CapabilityObservationBuffer, + ) - buffer = CapabilityObservationBuffer() + # The buffer gate runs first, so it must honour the same accepted + # set as the durable service; otherwise a configured evidence kind + # would be dropped here and the setting would have no effect. + buffer = CapabilityObservationBuffer( + classifier=CapabilityEvidenceClassifier.from_settings(self._settings) + ) self._capability_observation_buffer = buffer if not buffer.add_result(result): return @@ -5405,7 +5458,10 @@ def _observe_capability_result(self, result: Any) -> None: from leapflow.domain.environment_fingerprint import EnvironmentFingerprint from leapflow.domain.platform import PlatformManifest - from leapflow.learning.capability_observation import CapabilityObservationService + from leapflow.learning.capability_observation import ( + CapabilityEvidenceClassifier, + CapabilityObservationService, + ) from leapflow.plugins import get_registry from leapflow.plugins.adaptive_loop import AdaptiveLoopRequest, AdaptivePluginLoop from leapflow.storage.capability_observation_store import JsonCapabilityObservationStore @@ -5419,7 +5475,10 @@ def _observe_capability_result(self, result: Any) -> None: observation_store = JsonCapabilityObservationStore( profile_layout.capability_observations_path ) - observation_service = CapabilityObservationService(observation_store) + observation_service = CapabilityObservationService( + observation_store, + classifier=CapabilityEvidenceClassifier.from_settings(self._settings), + ) observation_record = observation_service.observe_result( result, environment=environment, @@ -5458,11 +5517,61 @@ def _observe_capability_result(self, result: Any) -> None: }, ) self._active_capability_plan = decision.plan.to_dict() + # Retire evidence whose gap this resolution closed. Without it the + # observation backlog only ever grows and keeps reporting capabilities + # the system already has. + for resolution in getattr(decision, "resolutions", ()): + self._record_coevolution_resolution(resolution) + if getattr(resolution, "unmet", True): + continue + capability = getattr(getattr(resolution, "requirement", None), "capability", "") + if capability: + observation_service.resolve_capability( + capability, reason=f"resolved in {loop_id}" + ) except (ImportError, AttributeError, RuntimeError, OSError, TypeError, ValueError) as exc: logger.debug("capability observation skipped: %s", exc, exc_info=True) # ── Helpers ────────────────────────────────────────────────────────── + @staticmethod + def _record_coevolution_resolution(resolution: Any) -> None: + """Report one resolution to the co-evolution buffer for the cold-path sweep. + + Exclusions are recorded as the excluded component's **scorer name** + (``risk_cost``, ``environment_affordance``, ...) rather than its prose. The + reaper needs to tell a durable exclusion from an environment one, and keying + that off a human-readable reason would stop working the moment the resolver + rewords it. + + Bookkeeping only: never raises, so a buffer problem cannot disturb the turn + that produced the resolution. + """ + try: + from leapflow.evolution.observations import record_resolution + + selected = getattr(resolution, "selected", None) + selected_id = "" + if selected is not None: + selected_id = str(getattr(getattr(selected, "candidate", None), "plugin_id", "")) + exclusions: dict[str, list[str]] = {} + for score in getattr(resolution, "candidates", ()) or (): + plugin_id = str(getattr(getattr(score, "candidate", None), "plugin_id", "")) + if not plugin_id or getattr(score, "eligible", False): + continue + exclusions[plugin_id] = [ + str(getattr(component, "scorer", "")) + for component in getattr(score, "components", ()) or () + if getattr(component, "excluded", False) + ] + record_resolution( + requirement=getattr(resolution, "requirement", None), + selected_plugin=selected_id, + exclusions=exclusions, + ) + except Exception: # noqa: BLE001 - observation must never affect execution + logger.debug("co-evolution resolution not recorded", exc_info=True) + def _budget_exhausted_response(self, messages: List[Dict[str, Any]]) -> str: """Response when the iteration hard cap is reached. diff --git a/src/leapflow/engine/session_factory.py b/src/leapflow/engine/session_factory.py index d9e95680..2fa4bc29 100644 --- a/src/leapflow/engine/session_factory.py +++ b/src/leapflow/engine/session_factory.py @@ -61,16 +61,67 @@ def set_store(self, store: Any) -> None: def record_success(self, plugin_id: str) -> None: before = self.level(plugin_id) super().record_success(plugin_id) - if self.level(plugin_id) != before: + after = self.level(plugin_id) + if after != before: self._flush() + self._trace_transition(plugin_id, before, after, hard=False) def record_failure(self, plugin_id: str, *, hard: bool = False) -> None: before = self.level(plugin_id) super().record_failure(plugin_id, hard=hard) + after = self.level(plugin_id) # ``hard`` freezes the plugin even when the reported level is unchanged # (already DRAFT), so persist it explicitly to record the frozen set. - if hard or self.level(plugin_id) != before: + if hard or after != before: self._flush() + self._trace_transition(plugin_id, before, after, hard=hard) + + def _trace_transition( + self, plugin_id: str, before: Any, after: Any, *, hard: bool + ) -> None: + """Emit the trust transition, which nothing else records durably. + + Placed here rather than in ``PluginTrustLedger`` for two reasons. The base + ledger is a pure domain object with no dependencies, and an observability + import does not belong in it; and this subclass has *already* computed the + before/after pair for the flush, so the transition is a fact in hand rather + than one that has to be detected a second time. + + Only the level a plugin currently holds is persisted. The moment it moved, + and the direction, exist nowhere else -- which is exactly why a promotion to + PRODUCTION or a freeze on an internal defect cannot be reconstructed after + the fact from the trust state alone. + """ + try: + from leapflow.domain.evolution_trace import EvolutionStage + from leapflow.telemetry.evolution_tap import emit_trace, is_enabled + + if not is_enabled(): + return + from_name = getattr(before, "name", str(before)) + to_name = getattr(after, "name", str(after)) + frozen = bool(self.is_frozen(plugin_id)) + emit_trace( + EvolutionStage.LEARN, + "trust_frozen" if hard else "trust_transition", + correlation={"plugin_id": plugin_id}, + summary=( + f"{plugin_id}: frozen at {to_name} by an internal defect" + if hard + else f"{plugin_id}: {from_name} -> {to_name}" + ), + detail={ + "plugin_id": plugin_id, + "from": from_name, + "to": to_name, + # A frozen plugin reports DRAFT, so the level alone cannot say + # whether it is new or permanently disqualified. + "frozen": frozen, + "hard_failure": hard, + }, + ) + except Exception: # noqa: BLE001 - trust accounting must not fail on telemetry + logger.debug("plugin trust: evolution trace failed", exc_info=True) def _flush(self) -> None: """Persist current ledger state; failures degrade to memory-only.""" @@ -230,6 +281,16 @@ def _wire_plugin_stats_sink( trust_ledger = _load_or_new_trust_ledger(store) usage_tracker = _load_or_new_usage_tracker(store) usage_tracker.set_trust_ledger(trust_ledger) + # Quarantine had no feed at all: trust demotion was immediate but a failing + # plugin was never disabled. The tracker is process-level so the tool-outcome + # sink that increments it and the cold-path sweep that drains it share one + # instance. + try: + from leapflow.evolution.observations import current_quarantine_tracker + + usage_tracker.set_quarantine_tracker(current_quarantine_tracker()) + except Exception: # noqa: BLE001 - governance wiring must not fail composition + logger.debug("quarantine tracker not attached", exc_info=True) advisor = PluginAdvisor(trust_ledger, usage_tracker) set_default_advisor(advisor) tracker.set_plugin_stats_sink(usage_tracker) diff --git a/src/leapflow/evolution/__init__.py b/src/leapflow/evolution/__init__.py new file mode 100644 index 00000000..64cb4db7 --- /dev/null +++ b/src/leapflow/evolution/__init__.py @@ -0,0 +1,25 @@ +"""Evolution ledger: the causal view of how the framework changed itself. + +Two halves that meet at :class:`~leapflow.domain.evolution_trace.EvolutionEpisode`: + +* :class:`EvolutionLedger` *reconstructs* episodes from records the adaptive loop + and observation layer already persist -- no probe, no new schema; +* :class:`LedgerEvolutionSink` *collects* live traces from probe sites for the + facts no store retains: registry mutations, trust transitions, world-model + proposals that were never admitted, and lifecycle openings. + +The first works alone; the second only fills the gaps the first cannot see. +""" + +from leapflow.evolution.ledger import DEFAULT_EPISODE_TTL_S, EvolutionLedger +from leapflow.evolution.sink import DEFAULT_BUFFER_SIZE, LedgerEvolutionSink +from leapflow.evolution.sweep import CoevolutionSweep, SweepOutcome + +__all__ = [ + "DEFAULT_BUFFER_SIZE", + "DEFAULT_EPISODE_TTL_S", + "CoevolutionSweep", + "EvolutionLedger", + "LedgerEvolutionSink", + "SweepOutcome", +] diff --git a/src/leapflow/evolution/ledger.py b/src/leapflow/evolution/ledger.py new file mode 100644 index 00000000..596f73f4 --- /dev/null +++ b/src/leapflow/evolution/ledger.py @@ -0,0 +1,509 @@ +"""Rebuild evolution episodes from the records the system already keeps. + +No probe is needed for this. The adaptive loop already persists one decision +record per run carrying requirements, resolutions, the plan, the mutation, the +policy decision, the registry version on both sides, and the ids of the +observations that motivated it. That record is three of the five stages already; +this module supplies the two ends it never joined: + +* **the cause** -- ``observation_ids`` reaches back into the observation store, so + an episode can say what environment evidence set it in motion, and (since the + requirement metadata now propagates them) carry the teacher's own hypothesis and + confidence when the world model authored it; +* **the consequence** -- the observation's ``status`` says whether the gap the + episode was supposed to close actually closed, stayed open, or *recurred*. + +Reading rather than writing is the whole point of this stage. It ships a causal +timeline with no new probe, no new schema and no change to the evolution path; the +durable ledger only becomes necessary later, when live traces arrive from mutation +points that no store can reconstruct after the fact. + +Two conclusions here are deliberately conservative: + +* A retired observation is reported as ``DECLARED_FITNESS``, never as verified. + The engine retires on re-resolution, which only proves a candidate *declared* it + provides the capability -- the recorded v0.7 defect was exactly a wrongly + selected tool retiring the evidence for its own gap. +* ``trust_now`` is a live read and is labelled as such. There is no history to + reconstruct a "before" from, and inventing one would put a number on the board + that never existed. +""" + +from __future__ import annotations + +import logging +from typing import Any, Mapping, Sequence + +from leapflow.domain.evolution_trace import ( + ABORTED, + COMMITTED, + DECLARED_FITNESS, + NOT_APPLICABLE, + OPEN, + REOPENED, + RESOLVED, + STILL_OPEN, + EvolutionEpisode, + EvolutionStage, + EvolutionTrace, +) + +logger = logging.getLogger(__name__) + +#: An episode with no closing trace is abandoned rather than left open forever. +DEFAULT_EPISODE_TTL_S = 1800.0 + +#: How many observations to index. Bounded because the index is built per cycle. +_OBSERVATION_INDEX_LIMIT = 400 + +#: Policy actions that close an episode by deciding *not* to change anything. +#: Reported as committed on purpose: "why the framework did not evolve" is as much +#: a part of transparency as why it did. +_NO_CHANGE_ACTIONS = frozenset({"none", "observe_only"}) + +#: Evidence kind -> the driver class a reader recognises. +_DRIVER_BY_EVIDENCE = { + "world_model_intent": "world_model", + "unknown_tool": "unknown_tool", + "interface_drift": "environment_probe", + "affordance_removed": "environment_probe", +} + + +class EvolutionLedger: + """Assemble recent evolution episodes from existing profile-scoped stores. + + Every store is optional. With only the plan store the timeline still renders, + just without cause or consequence; with none of them ``recent_episodes`` + returns empty and the caller degrades to a live snapshot. A ledger that + refused to work without every input would make the panel all-or-nothing. + """ + + def __init__( + self, + *, + plan_store: Any, + observation_store: Any = None, + trust_ledger: Any = None, + episode_ttl_s: float = DEFAULT_EPISODE_TTL_S, + ) -> None: + self._plans = plan_store + self._observations = observation_store + self._trust = trust_ledger + self._ttl = max(0.0, float(episode_ttl_s)) + + def recent_episodes(self, *, limit: int = 20, now: float = 0.0) -> tuple[EvolutionEpisode, ...]: + """Return newest-first episodes rebuilt from decision records. + + Never raises: this feeds a transparency panel, and a ledger that fails + would take the panel with it while reporting nothing about why. + """ + try: + records = list(self._plans.list_records(limit=max(1, int(limit)))) + except Exception: # noqa: BLE001 - degraded timeline, not a fault + logger.debug("evolution ledger: plan records unreadable", exc_info=True) + return () + index = self._observation_index() + episodes: list[EvolutionEpisode] = [] + for record in records: + if not isinstance(record, Mapping): + continue + try: + episodes.append(self._episode(record, index, now)) + except Exception: # noqa: BLE001 - one bad record must not blank the timeline + logger.debug("evolution ledger: record skipped", exc_info=True) + return tuple(episodes) + + # ── observation index (the cause, and the consequence) ──────────────── + + def _observation_index(self) -> dict[str, Mapping[str, Any]]: + if self._observations is None: + return {} + try: + records = self._observations.list_observations(limit=_OBSERVATION_INDEX_LIMIT) + except Exception: # noqa: BLE001 + logger.debug("evolution ledger: observations unreadable", exc_info=True) + return {} + return { + str(record.get("observation_id") or ""): record + for record in records + if isinstance(record, Mapping) and record.get("observation_id") + } + + # ── one episode ─────────────────────────────────────────────────────── + + def _episode( + self, + record: Mapping[str, Any], + index: Mapping[str, Mapping[str, Any]], + now: float, + ) -> EvolutionEpisode: + record_id = str(record.get("record_id") or "") + opened_at = float(record.get("created_at") or 0.0) + observations = [ + index[obs_id] + for obs_id in (str(item) for item in record.get("observation_ids") or ()) + if obs_id in index + ] + requirements = [r for r in record.get("requirements") or [] if isinstance(r, Mapping)] + mutation = dict(record.get("mutation") or {}) + policy = dict(record.get("policy_decision") or {}) + proposal = dict(record.get("proposal") or {}) + + before = int(record.get("registry_version_before") or -1) + after = int(record.get("registry_version_after") or -1) + capability = self._capability(requirements, observations) + plugin_id = str(mutation.get("plugin_id") or "") + mutation_action = str(mutation.get("action") or "") + gap_closure = self._gap_closure(observations, mutation_action, after > before >= 0) + declared = self._declared(requirements) + + traces = self._traces( + record_id, record, observations, requirements, mutation, policy, before, after + ) + status, closed_at = self._status(record, opened_at, mutation_action, after > before >= 0, now) + + return EvolutionEpisode( + episode_id=f"ep-{record_id}" if record_id else f"ep-{int(opened_at)}", + opened_at=opened_at, + status=status, + traces=traces, + closed_at=closed_at, + driver=self._driver(observations, requirements, record), + capability=capability, + intent_id=str(declared.get("intent_id") or ""), + hypothesis=str(declared.get("hypothesis") or ""), + confidence=self._confidence(declared), + mutation_action=mutation_action or "none", + plugin_id=plugin_id, + registry_before=before, + registry_after=after, + # ``proposal.status`` on a decision record is the *acquisition + # lifecycle* vocabulary. ``review_status`` is a different store's + # answer to a different question ("should a human accept this"), so it + # stays empty here rather than borrowing this value and conflating two + # vocabularies the code explicitly warns must not be merged. + lifecycle_status=str(proposal.get("status") or ""), + policy_action=str(policy.get("action") or ""), + autonomy_level=str(policy.get("autonomy_level") or ""), + gap_closure=gap_closure, + # Only ever declared fitness today: the engine retires an observation + # when re-resolution reports the requirement met, which is not + # evidence the capability works. + verification_tier=DECLARED_FITNESS if gap_closure == RESOLVED else "", + effect_verdict="", + trust_at_decision=self._trust_at_decision(record, proposal), + trust_now=self._trust_now(plugin_id), + outcome=self._outcome(mutation_action, gap_closure, policy, after > before >= 0), + ) + + # ── stage traces ────────────────────────────────────────────────────── + + def _traces( + self, + record_id: str, + record: Mapping[str, Any], + observations: Sequence[Mapping[str, Any]], + requirements: Sequence[Mapping[str, Any]], + mutation: Mapping[str, Any], + policy: Mapping[str, Any], + before: int, + after: int, + ) -> tuple[EvolutionTrace, ...]: + """Build one trace per stage the record can actually evidence.""" + traces: list[EvolutionTrace] = [] + base = {"record_id": record_id} if record_id else {} + + for observation in observations: + result = dict(observation.get("result") or {}) + kind = str(result.get("error_type") or "environment_delta") + traces.append( + EvolutionTrace( + stage=EvolutionStage.OBSERVE, + kind=kind, + ts=float(observation.get("first_seen_at") or 0.0), + correlation={ + **base, + "observation_id": str(observation.get("observation_id") or ""), + }, + summary=str(result.get("evidence") or result.get("recovery_hint") or kind), + detail={ + "occurrence_count": int(observation.get("occurrence_count") or 0), + "status": self._observation_status(observation), + "result": result, + }, + ) + ) + + if requirements: + traces.append( + EvolutionTrace( + stage=EvolutionStage.ORIENT, + kind="capability_gap", + ts=float(record.get("created_at") or 0.0), + correlation={ + **base, + "requirement_id": str(requirements[0].get("requirement_id") or ""), + }, + summary=", ".join( + str(r.get("capability") or "") for r in requirements if r.get("capability") + ), + detail={"requirements": [dict(r) for r in requirements]}, + ) + ) + + if policy or record.get("resolutions"): + traces.append( + EvolutionTrace( + stage=EvolutionStage.DECIDE, + kind="policy_decision" if policy else "resolution", + ts=float(record.get("created_at") or 0.0), + correlation=dict(base), + summary=str(policy.get("reason") or "capability resolved"), + detail={ + "policy_decision": dict(policy), + # Kept whole: the per-candidate score components are the + # only record of why a candidate lost, which is the half of + # "decision transparency" a selected-only view drops. + "resolutions": [ + dict(r) for r in record.get("resolutions") or [] if isinstance(r, Mapping) + ], + }, + ) + ) + + if mutation: + traces.append( + EvolutionTrace( + stage=EvolutionStage.ACT, + kind="plugin_mutation", + ts=float(record.get("created_at") or 0.0), + correlation={ + **base, + "plugin_id": str(mutation.get("plugin_id") or ""), + "registry_version": str(after), + }, + summary=f"{mutation.get('action') or 'none'} {mutation.get('plugin_id') or ''}".strip(), + detail={ + "mutation": dict(mutation), + "registry_before": before, + "registry_after": after, + }, + ) + ) + + for observation in observations: + status = self._observation_status(observation) + reopened = self._reopened(observation) + # A recurrence is recorded by flipping ``status`` back to ``open``, so + # testing the status alone would skip the single most important + # outcome there is: a gap that was closed and came back. + if status == "open" and not reopened: + continue + traces.append( + EvolutionTrace( + stage=EvolutionStage.LEARN, + kind="observation_reopened" if reopened else "observation_resolved", + ts=float(observation.get("last_seen_at") or 0.0), + correlation={ + **base, + "observation_id": str(observation.get("observation_id") or ""), + }, + summary=str(observation.get("status_reason") or status), + detail={"status": status, "reopened": reopened}, + ) + ) + + governance = [g for g in record.get("governance_results") or [] if isinstance(g, Mapping)] + for entry in governance: + traces.append( + EvolutionTrace( + stage=EvolutionStage.LEARN, + kind="governance_outcome", + ts=float(record.get("created_at") or 0.0), + correlation={**base, "plugin_id": str(entry.get("plugin_id") or "")}, + summary=str(entry.get("action") or ""), + detail=dict(entry), + ) + ) + + return tuple(sorted(traces, key=lambda trace: (trace.ts, trace.stage.value))) + + # ── derivations ─────────────────────────────────────────────────────── + + @staticmethod + def _observation_status(observation: Mapping[str, Any]) -> str: + """Read the lifecycle status, matching how the store itself defaults it. + + A newly written observation carries no ``status`` field at all; the store's + own ``unresolved()`` treats that absence as open, so this must too or a + fresh gap would read as closed. + """ + return str(observation.get("status") or "open") + + @staticmethod + def _reopened(observation: Mapping[str, Any]) -> bool: + """Whether this observation was retired and then recurred. + + The store records a recurrence by flipping ``status`` back to ``open`` and + writing a reason that says so, which is the only durable trace that a + closed gap came back. + """ + return "reopened" in str(observation.get("status_reason") or "").lower() + + def _gap_closure( + self, + observations: Sequence[Mapping[str, Any]], + mutation_action: str, + framework_changed: bool, + ) -> str: + if not observations: + return NOT_APPLICABLE + if any(self._reopened(observation) for observation in observations): + return REOPENED + statuses = {self._observation_status(observation) for observation in observations} + if statuses == {"open"}: + # The framework changed and the motivating gap is still open: the + # acquisition ran and did not (yet) help. Worth distinguishing from an + # episode that never acted at all. + return STILL_OPEN if (framework_changed or mutation_action not in ("", "none")) else OPEN + if "open" not in statuses: + return RESOLVED + return STILL_OPEN + + @staticmethod + def _capability( + requirements: Sequence[Mapping[str, Any]], observations: Sequence[Mapping[str, Any]] + ) -> str: + for requirement in requirements: + capability = str(requirement.get("capability") or "") + if capability: + return capability + for observation in observations: + result = dict(observation.get("result") or {}) + capability = str(result.get("capability") or result.get("original_tool_name") or "") + if capability: + return capability + return "" + + @staticmethod + def _declared(requirements: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + """Pull the world model's own words out of the requirement metadata. + + These travel on the requirement because the declared-evidence path + propagates them; without that they would exist only inside the intent and + never reach anything durable. + """ + for requirement in requirements: + metadata = dict(requirement.get("metadata") or {}) + if metadata.get("intent_id") or metadata.get("hypothesis"): + return { + "intent_id": metadata.get("intent_id"), + # The requirement carries the hypothesis as its evidence text. + "hypothesis": metadata.get("hypothesis") or requirement.get("evidence"), + "confidence": metadata.get("confidence"), + } + return {} + + @staticmethod + def _confidence(declared: Mapping[str, Any]) -> float: + try: + return max(0.0, min(1.0, float(declared.get("confidence") or 0.0))) + except (TypeError, ValueError): + return 0.0 + + @staticmethod + def _driver( + observations: Sequence[Mapping[str, Any]], + requirements: Sequence[Mapping[str, Any]], + record: Mapping[str, Any], + ) -> str: + """Classify what set this episode in motion, from declarations only.""" + for observation in observations: + result = dict(observation.get("result") or {}) + driver = _DRIVER_BY_EVIDENCE.get(str(result.get("error_type") or "")) + if driver: + return driver + for requirement in requirements: + origin = str(requirement.get("origin") or "") + if origin == "world_model": + return "world_model" + if origin == "explicit_request": + return "manual" + if origin: + return origin + return str(record.get("source") or "") or "unknown" + + def _status( + self, + record: Mapping[str, Any], + opened_at: float, + mutation_action: str, + framework_changed: bool, + now: float, + ) -> tuple[str, float]: + policy_action = str(dict(record.get("policy_decision") or {}).get("action") or "") + if framework_changed: + return COMMITTED, opened_at + if policy_action in _NO_CHANGE_ACTIONS: + return COMMITTED, opened_at + if mutation_action and mutation_action != "none" and not framework_changed: + # A mutation was attempted and the registry did not move: unresolved, + # not committed. Left open so the operator sees an attempt that had no + # effect rather than a clean conclusion. + return (ABORTED if self._expired(opened_at, now) else OPEN), 0.0 + return (ABORTED if self._expired(opened_at, now) else OPEN), 0.0 + + def _expired(self, opened_at: float, now: float) -> bool: + return bool(now and self._ttl and (now - opened_at) > self._ttl) + + @staticmethod + def _trust_at_decision(record: Mapping[str, Any], proposal: Mapping[str, Any]) -> str: + trust_state = dict(proposal.get("trust_state") or {}) + level = str(trust_state.get("trust_level") or trust_state.get("level") or "") + if level: + return level + for entry in record.get("governance_results") or []: + if isinstance(entry, Mapping) and entry.get("trust_level"): + return str(entry["trust_level"]) + return "" + + def _trust_now(self, plugin_id: str) -> str: + """Live trust for the mutated plugin, or empty when it cannot be read. + + Named ``_now`` rather than ``_after`` on purpose: there is no stored + history to reconstruct a before/after pair from, and presenting a live + reading as an "after" would imply a comparison that was never made. + """ + if not plugin_id or self._trust is None: + return "" + try: + return str(self._trust.level(plugin_id).name) + except Exception: # noqa: BLE001 + return "" + + @staticmethod + def _outcome( + mutation_action: str, + gap_closure: str, + policy: Mapping[str, Any], + framework_changed: bool, + ) -> str: + """One phrase a reader can scan, covering the four interesting endings.""" + if gap_closure == REOPENED: + return "regressed" + if framework_changed and gap_closure == RESOLVED: + return f"{mutation_action or 'changed'}; gap closed (declared fitness)" + if framework_changed and gap_closure == STILL_OPEN: + return f"{mutation_action or 'changed'}; gap still open" + if framework_changed: + return mutation_action or "changed" + action = str(policy.get("action") or "") + if action in _NO_CHANGE_ACTIONS: + return f"no action ({action})" + if mutation_action and mutation_action != "none": + return f"{mutation_action} attempted; registry unchanged" + return "no action" + + +__all__ = ["DEFAULT_EPISODE_TTL_S", "EvolutionLedger"] diff --git a/src/leapflow/evolution/observations.py b/src/leapflow/evolution/observations.py new file mode 100644 index 00000000..30dc9339 --- /dev/null +++ b/src/leapflow/evolution/observations.py @@ -0,0 +1,252 @@ +"""What the co-evolution sweep needs to see, collected where it is produced. + +The cold-path sweep verifies effects, drains quarantine candidates and scans for +residue — but the facts it needs are produced in three different layers: the engine +resolves requirements, the self-management tools install artifacts, and the usage +sink sees every tool outcome. Having the CLI context reach down for those would +invert the dependency (engine must not know about the CLI), so producers write here +and the sweep reads here. + +Deliberately mirrors ``telemetry/evolution_tap``: a process-level accessor, a +bounded buffer, and writes that never raise. Two properties matter: + +* **Bounded.** Every buffer is a ``deque`` with a cap, because governance state must + not grow with session length. Losing the oldest observation degrades a later sweep; + an unbounded buffer degrades the process. +* **Hot-path safe.** ``record_tool_outcome`` is a dict lookup and a deque append. It + performs no I/O and never awaits, so it is safe to call from the tool-outcome sink. + +**A verification needs three things**: the requirement (which carries the declared +``expected_effect``), the plugin that was selected to serve it, and what was actually +observed. The binding is built from resolutions — a resolution says "this plugin was +chosen for this requirement" — and completed when that plugin's tool reports an +outcome. Only plugins the system *acquired* are bound, because verifying a +hand-installed tool against a world-model expectation is not meaningful. + +**Known limitation, by design rather than omission:** an outcome carries an observed +effect only when the producer declares one. Without it, a *successful* call verifies +as ``unverifiable`` (we genuinely do not know whether the effect landed) while a +*failed* call still refutes. So today this channel can refute an acquisition but not +confirm one; confirming requires tools to report their effect. +""" + +from __future__ import annotations + +import logging +from collections import deque +from typing import Any, Mapping, Sequence + +from leapflow.domain.capability_requirement import CapabilityRequirement + +logger = logging.getLogger(__name__) + +#: Caps chosen so a long session cannot grow governance state without bound. +MAX_RESOLUTIONS = 64 +MAX_VERIFICATIONS = 32 +MAX_ACQUIRED = 64 + + +class CoevolutionObservations: + """Bounded, process-level collection point for co-evolution facts.""" + + def __init__( + self, + *, + max_resolutions: int = MAX_RESOLUTIONS, + max_verifications: int = MAX_VERIFICATIONS, + max_acquired: int = MAX_ACQUIRED, + ) -> None: + self._resolutions: deque[dict[str, Any]] = deque(maxlen=max(1, max_resolutions)) + self._verifications: deque[ + tuple[CapabilityRequirement, dict[str, Any], str] + ] = deque(maxlen=max(1, max_verifications)) + self._acquired: deque[str] = deque(maxlen=max(1, max_acquired)) + # plugin_id -> the requirement it was selected to serve. + self._bindings: dict[str, CapabilityRequirement] = {} + # plugin_id -> the workspaces whose traffic exercised it. Governance state is + # process-global because plugins are, so a streak can be driven by one + # workspace and disable a plugin another one was using. That is consistent with + # how trust already works -- a plugin that keeps failing is broken as *code* -- + # but it must not be invisible, so the contributing workspaces travel with the + # decision and appear in its trace. + self._workspaces: dict[str, set[str]] = {} + + # ── producers ────────────────────────────────────────────────────────── + + def record_acquisition(self, plugin_id: str) -> None: + """Note that self-evolution installed this plugin.""" + plugin_id = str(plugin_id or "") + if plugin_id and plugin_id not in self._acquired: + self._acquired.append(plugin_id) + + def record_resolution( + self, + *, + requirement: CapabilityRequirement | None = None, + selected_plugin: str = "", + exclusions: Mapping[str, Sequence[str]] | None = None, + ) -> None: + """Record one resolution outcome, and bind it if it chose an acquired plugin.""" + selected = str(selected_plugin or "") + self._resolutions.append( + { + "selected_plugin": selected, + "exclusions": { + str(pid): [str(r) for r in reasons] + for pid, reasons in dict(exclusions or {}).items() + }, + } + ) + # Only acquired plugins are bound: verifying a hand-installed tool against a + # world-model expectation would be measuring the wrong thing. + if requirement is not None and selected and selected in self._acquired: + self._bindings[selected] = requirement + + def record_tool_outcome( + self, + plugin_id: str, + tool_name: str, + ok: bool, + *, + observed_effect: str = "", + workspace: str = "", + ) -> None: + """Hot-path safe: pair an outcome with its bound requirement, if any. + + ``workspace`` is recorded for attribution only. It never changes a verdict -- + the same plugin id means the same code regardless of who called it -- but it + makes a cross-workspace quarantine auditable instead of mysterious. + """ + plugin_id = str(plugin_id or "") + if plugin_id and workspace: + self._workspaces.setdefault(plugin_id, set()).add(str(workspace)) + requirement = self._bindings.get(plugin_id) + if requirement is None: + return + self._verifications.append( + ( + requirement, + {"ok": bool(ok), "observed_effect": str(observed_effect or ""), + "tool_name": str(tool_name or "")}, + plugin_id, + ) + ) + + # ── consumer (the sweep) ─────────────────────────────────────────────── + + def drain_verifications( + self, + ) -> tuple[tuple[CapabilityRequirement, dict[str, Any], str], ...]: + """Take the pending verifications, clearing them. + + Draining rather than reading keeps a sweep from re-verifying an outcome it + already governed, which would double-count trust. + """ + drained = tuple(self._verifications) + self._verifications.clear() + return drained + + def resolutions(self) -> tuple[dict[str, Any], ...]: + return tuple(self._resolutions) + + def acquired_plugin_ids(self) -> tuple[str, ...]: + return tuple(self._acquired) + + def contributing_workspaces(self, plugin_id: str) -> tuple[str, ...]: + """Which workspaces exercised this plugin, for governance attribution.""" + return tuple(sorted(self._workspaces.get(str(plugin_id or ""), ()))) + + def stats(self) -> dict[str, int]: + return { + "resolutions": len(self._resolutions), + "pending_verifications": len(self._verifications), + "acquired": len(self._acquired), + "bindings": len(self._bindings), + } + + def reset(self) -> None: + self._resolutions.clear() + self._verifications.clear() + self._acquired.clear() + self._bindings.clear() + self._workspaces.clear() + + +_OBSERVATIONS: CoevolutionObservations | None = None +_QUARANTINE_TRACKER: Any = None + + +def current_quarantine_tracker() -> Any: + """The process-level consecutive-failure tracker, created on first use. + + Process-level for the same reason the registry is: plugins are shared by every + session, so a failure streak belongs to the plugin rather than to whoever happened + to call it. It also has to be *one* instance -- the tool-outcome sink increments it + and the cold-path sweep drains it, and two instances would mean the sweep draining + a tracker nobody ever fed. + """ + global _QUARANTINE_TRACKER + if _QUARANTINE_TRACKER is None: + from leapflow.learning.outcome_governance_feed import QuarantineCandidateTracker + + _QUARANTINE_TRACKER = QuarantineCandidateTracker() + return _QUARANTINE_TRACKER + + +def install_quarantine_tracker(tracker: Any) -> None: + """Replace the process tracker. ``None`` resets it; used by tests.""" + global _QUARANTINE_TRACKER + _QUARANTINE_TRACKER = tracker + + +def current_observations() -> CoevolutionObservations: + """The process-level buffer, created on first use.""" + global _OBSERVATIONS + if _OBSERVATIONS is None: + _OBSERVATIONS = CoevolutionObservations() + return _OBSERVATIONS + + +def install_observations(buffer: CoevolutionObservations | None) -> None: + """Replace the process buffer. ``None`` resets it; used by tests.""" + global _OBSERVATIONS + _OBSERVATIONS = buffer + + +def record_acquisition(plugin_id: str) -> None: + """Module-level convenience for producers; never raises.""" + try: + current_observations().record_acquisition(plugin_id) + except Exception: # noqa: BLE001 - a producer must never fail on bookkeeping + logger.debug("coevolution observations: acquisition not recorded", exc_info=True) + + +def record_resolution(**kwargs: Any) -> None: + """Module-level convenience for producers; never raises.""" + try: + current_observations().record_resolution(**kwargs) + except Exception: # noqa: BLE001 + logger.debug("coevolution observations: resolution not recorded", exc_info=True) + + +def record_tool_outcome(plugin_id: str, tool_name: str, ok: bool, **kwargs: Any) -> None: + """Module-level convenience for the hot path; never raises.""" + try: + current_observations().record_tool_outcome(plugin_id, tool_name, ok, **kwargs) + except Exception: # noqa: BLE001 + logger.debug("coevolution observations: outcome not recorded", exc_info=True) + + +__all__ = [ + "MAX_ACQUIRED", + "MAX_RESOLUTIONS", + "MAX_VERIFICATIONS", + "CoevolutionObservations", + "current_observations", + "current_quarantine_tracker", + "install_observations", + "install_quarantine_tracker", + "record_acquisition", + "record_resolution", + "record_tool_outcome", +] diff --git a/src/leapflow/evolution/sink.py b/src/leapflow/evolution/sink.py new file mode 100644 index 00000000..78620fc3 --- /dev/null +++ b/src/leapflow/evolution/sink.py @@ -0,0 +1,112 @@ +"""LedgerEvolutionSink: accept traces on the hot side, persist on the cold side. + +The probe's contract is "accept and return", so ``record`` only appends to a bounded +deque. Persistence happens when someone calls :meth:`flush` -- the daemon's monitor +cycle, or process exit -- which keeps a file write out of the plugin registry's +version bump and the trust ledger's level transition. + +The buffer is bounded and drops *oldest* on overflow. That is the right direction +for this data: a burst means the framework is churning, and the newest traces +describe where it ended up. +""" + +from __future__ import annotations + +import atexit +import logging +from collections import deque +from typing import Any + +from leapflow.domain.evolution_trace import EvolutionTrace + +logger = logging.getLogger(__name__) + +#: Bounded so a runaway producer cannot grow memory between flushes. +DEFAULT_BUFFER_SIZE = 512 + + +class LedgerEvolutionSink: + """Buffering :class:`~leapflow.domain.evolution_trace.EvolutionTraceSink`. + + Satisfies the Protocol structurally; no inheritance, so a test can substitute + anything with a ``record`` method. + """ + + def __init__( + self, + *, + store: Any = None, + buffer_size: int = DEFAULT_BUFFER_SIZE, + publish: Any = None, + ) -> None: + self._store = store + self._buffer: deque[EvolutionTrace] = deque(maxlen=max(1, int(buffer_size))) + # Optional callable invoked per trace after buffering, for event + # re-publication. Kept as a plain callable so this module needs no + # dependency on the event bus. + self._publish = publish + self._dropped = 0 + self._recorded = 0 + + # ── sink side (must stay O(1) and never raise) ───────────────────────── + + def record(self, trace: EvolutionTrace) -> None: + """Buffer one trace. Called from probe sites, so it does no I/O.""" + if len(self._buffer) == self._buffer.maxlen: + # Counted rather than silently discarded: a non-zero drop count means + # the panel is showing an incomplete history, which a reader must be + # able to find out. + self._dropped += 1 + self._buffer.append(trace) + self._recorded += 1 + if self._publish is not None: + try: + self._publish(trace) + except Exception: # noqa: BLE001 - publication is best-effort + logger.debug("evolution sink: publish failed", exc_info=True) + + # ── cold side ───────────────────────────────────────────────────────── + + def flush(self) -> int: + """Persist and clear the buffer. Returns the number of traces written. + + Drains before writing so a store failure cannot cause the same traces to + be retried forever; they are lost, and ``dropped`` records that they were. + """ + if not self._buffer: + return 0 + pending = list(self._buffer) + self._buffer.clear() + if self._store is None: + return 0 + try: + return int(self._store.append(trace.to_dict() for trace in pending)) + except Exception: # noqa: BLE001 - a lost trace must not break the cycle + logger.debug("evolution sink: flush failed", exc_info=True) + self._dropped += len(pending) + return 0 + + def pending(self) -> tuple[EvolutionTrace, ...]: + """Buffered traces not yet flushed, for a reader that wants live state.""" + return tuple(self._buffer) + + @property + def stats(self) -> dict[str, int]: + return { + "recorded": self._recorded, + "buffered": len(self._buffer), + "dropped": self._dropped, + } + + def register_atexit(self) -> None: + """Flush on interpreter exit, so a clean shutdown loses nothing.""" + atexit.register(self._flush_quietly) + + def _flush_quietly(self) -> None: + try: + self.flush() + except Exception: # noqa: BLE001 - exit-time best effort + logger.debug("evolution sink: exit flush failed", exc_info=True) + + +__all__ = ["DEFAULT_BUFFER_SIZE", "LedgerEvolutionSink"] diff --git a/src/leapflow/evolution/sweep.py b/src/leapflow/evolution/sweep.py new file mode 100644 index 00000000..3dadcf55 --- /dev/null +++ b/src/leapflow/evolution/sweep.py @@ -0,0 +1,266 @@ +"""The cold-path co-evolution sweep: verify, govern, reclaim. + +Three capabilities existed in the tree with no caller, which the evolution +dashboard reported as ``NO_EVIDENCE`` rows rather than treating the module's +presence as proof: + +* ``CapabilityEffectVerifier`` -- closures rested on *declared* fitness (a + candidate says it provides the capability and its affordances are present) + instead of *observed* effect; +* ``QuarantineCandidateTracker`` -- trust demotion was live but quarantine had no + feed, so a plugin could fail indefinitely without being disabled; +* ``UnselectableArtifactReaper`` -- an artifact no admissible requirement can + select produces no outcomes, so it is never quarantined and stays registered as + untracked residue. + +This module is the single call site that closes all three. It runs on a **cold +path** (the session-end learning boundary, beside the world-model driver), never +inside a turn: governance is required to add no per-turn cost, and every step here +writes to a store or awaits a lifecycle actor. + +Every step emits an evolution trace, because a transition that is not recorded is +not explainable, reproducible, or reversible. The sweep records its *no-op* and +*rejected* branches too -- "nothing to verify" and "no reclamation candidate" are +facts a reader needs in order to distinguish a quiet system from a switched-off +one. + +Nothing here mutates the registry directly. Disabling a plugin stays with the +lifecycle actor, reached through ``LifecycleGovernor``. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Mapping, Sequence + +from leapflow.domain.capability_requirement import CapabilityRequirement +from leapflow.domain.evolution_trace import EvolutionStage +from leapflow.learning.capability_effect_verifier import ( + CapabilityEffectVerifier, + EffectVerdict, + ReclamationCandidate, + UnselectableArtifactReaper, +) +from leapflow.learning.outcome_governance_feed import ( + QuarantineCandidateTracker, + drain_quarantine_candidates, +) + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class SweepOutcome: + """What one cold-path sweep observed and did. + + Reported into the session-end observability payload so a reader can tell a + quiet sweep from an absent one -- the distinction the dashboard's + ``NO_EVIDENCE`` rows exist to make. + """ + + verdicts: tuple[EffectVerdict, ...] = () + quarantined: tuple[Mapping[str, Any], ...] = () + reclamation: tuple[ReclamationCandidate, ...] = () + + @property + def verified(self) -> int: + return sum(1 for v in self.verdicts if v.verified is True) + + @property + def refuted(self) -> int: + return sum(1 for v in self.verdicts if v.verified is False) + + @property + def unverifiable(self) -> int: + return sum(1 for v in self.verdicts if v.verified is None) + + def to_dict(self) -> dict[str, Any]: + return { + "effect_verified": self.verified, + "effect_refuted": self.refuted, + "effect_unverifiable": self.unverifiable, + "quarantined": len(self.quarantined), + "reclamation_candidates": [c.plugin_id for c in self.reclamation], + } + + +@dataclass +class CoevolutionSweep: + """Runs effect verification, quarantine governance and reclamation. + + All collaborators are optional: a sweep with nothing wired emits the + corresponding no-op traces and returns an empty outcome, which is what keeps + this safe to call unconditionally at session end. + """ + + governor: Any = None + tracker: QuarantineCandidateTracker | None = None + verifier: CapabilityEffectVerifier = field(default_factory=CapabilityEffectVerifier) + reaper: UnselectableArtifactReaper = field(default_factory=UnselectableArtifactReaper) + proposal_ids: Mapping[str, str] = field(default_factory=dict) + + async def run( + self, + *, + verifications: Sequence[tuple[CapabilityRequirement, Mapping[str, Any], str]] = (), + acquired_plugin_ids: Sequence[str] = (), + resolutions: Sequence[Mapping[str, Any]] = (), + ) -> SweepOutcome: + """Verify effects, drain quarantine candidates, then scan for residue. + + ``verifications`` is a sequence of ``(requirement, outcome, plugin_id)``: + what an acquired capability was asked to do and what was observed. Order + matters -- verification runs first so a refuted verdict can itself feed the + quarantine drain in the same sweep. + """ + verdicts = await self._verify(verifications) + quarantined = await self._drain() + reclamation = self._reclaim(acquired_plugin_ids, resolutions) + return SweepOutcome(verdicts, quarantined, reclamation) + + # ── effect verification (L3 closure) ────────────────────────────────── + + async def _verify( + self, verifications: Sequence[tuple[CapabilityRequirement, Mapping[str, Any], str]] + ) -> tuple[EffectVerdict, ...]: + if not verifications: + self._emit( + EvolutionStage.LEARN, "effect_verification", + summary="nothing to verify this session", + detail={"observed": 0, "no_op": True}, + ) + return () + + verdicts: list[EffectVerdict] = [] + for requirement, outcome, plugin_id in verifications: + try: + verdict = self.verifier.verify(requirement, outcome, plugin_id=plugin_id) + except (TypeError, ValueError, AttributeError): + logger.debug("sweep: verification failed", exc_info=True) + continue + verdicts.append(verdict) + self._emit( + EvolutionStage.LEARN, "effect_verification", + correlation={"plugin_id": verdict.plugin_id, "capability": verdict.capability}, + summary=f"{verdict.capability}: {verdict.reason}", + detail=verdict.to_dict(), + ) + # Only a decided verdict may move trust; "unverifiable" must not + # quarantine a plugin for a missing declaration. + if verdict.should_record_outcome: + await self._record(verdict) + return tuple(verdicts) + + async def _record(self, verdict: EffectVerdict) -> None: + """Feed a decided verdict into trust/lifecycle governance.""" + if self.governor is None: + return + try: + await self.governor.record_outcome( + proposal_id=self.proposal_ids.get(verdict.plugin_id, ""), + plugin_id=verdict.plugin_id, + tool_name=verdict.plugin_id, + ok=bool(verdict.verified), + failure_class="" if verdict.verified else verdict.reason, + ) + except Exception: # noqa: BLE001 - governance must not break the sweep + logger.debug("sweep: governance rejected a verdict", exc_info=True) + + # ── quarantine feed (cold-path drain) ───────────────────────────────── + + async def _drain(self) -> tuple[Mapping[str, Any], ...]: + if self.tracker is None or self.governor is None: + self._emit( + EvolutionStage.ACT, "quarantine_drain", + summary="quarantine feed not available", + detail={"pending": 0, "no_op": True}, + ) + return () + pending = self.tracker.pending() + if not pending: + self._emit( + EvolutionStage.ACT, "quarantine_drain", + summary="no quarantine candidate this session", + detail={"pending": 0, "no_op": True}, + ) + return () + handled = await drain_quarantine_candidates( + self.tracker, self.governor, proposal_ids=self.proposal_ids + ) + for entry in handled: + plugin_id = str(entry.get("plugin_id") or "") + detail = dict(entry) + # Governance state is process-global because plugins are, so a streak can + # be driven by one workspace and disable a plugin another was using. The + # contributing workspaces travel with the decision so that is auditable + # rather than mysterious. + workspaces = self._contributing_workspaces(plugin_id) + if workspaces: + detail["contributing_workspaces"] = list(workspaces) + detail["cross_workspace"] = len(workspaces) > 1 + self._emit( + EvolutionStage.ACT, "quarantine_drain", + correlation={"plugin_id": plugin_id}, + summary=f"{entry.get('plugin_id')}: {entry.get('action')}", + detail=detail, + ) + return handled + + @staticmethod + def _contributing_workspaces(plugin_id: str) -> tuple[str, ...]: + """Attribution lookup; absence is normal and must never break the drain.""" + try: + from leapflow.evolution.observations import current_observations + + return current_observations().contributing_workspaces(plugin_id) + except Exception: # noqa: BLE001 + return () + + # ── reclamation (residue) ───────────────────────────────────────────── + + def _reclaim( + self, + acquired_plugin_ids: Sequence[str], + resolutions: Sequence[Mapping[str, Any]], + ) -> tuple[ReclamationCandidate, ...]: + try: + found = self.reaper.candidates( + acquired_plugin_ids=acquired_plugin_ids, resolutions=resolutions + ) + except (TypeError, ValueError, AttributeError): + logger.debug("sweep: reclamation scan failed", exc_info=True) + return () + if not found: + self._emit( + EvolutionStage.LEARN, "reclamation", + summary="no reclamation candidate", + detail={ + "acquired": len(acquired_plugin_ids), + "resolutions": len(resolutions), + "no_op": True, + }, + ) + return () + for candidate in found: + self._emit( + EvolutionStage.LEARN, "reclamation", + correlation={"plugin_id": candidate.plugin_id}, + summary=f"{candidate.plugin_id}: {candidate.reason}", + detail=candidate.to_dict(), + ) + return found + + @staticmethod + def _emit(stage: EvolutionStage, kind: str, **kwargs: Any) -> None: + """Record one sweep fact. Observability must never affect the observed.""" + try: + from leapflow.telemetry.evolution_tap import emit_trace, is_enabled + + if is_enabled(): + emit_trace(stage, kind, **kwargs) + except Exception: # noqa: BLE001 + logger.debug("sweep: trace emission failed", exc_info=True) + + +__all__ = ["CoevolutionSweep", "SweepOutcome"] diff --git a/src/leapflow/layout.py b/src/leapflow/layout.py index 29ada826..11cc5e35 100644 --- a/src/leapflow/layout.py +++ b/src/leapflow/layout.py @@ -436,6 +436,14 @@ def capability_plans_path(self) -> Path: # candidate scores, selected tools, and declarative plans. return self.root / "plugins" / "capability_plans.json" + @property + def evolution_traces_path(self) -> Path: + # Profile-scoped framework self-evolution traces: registry mutations, trust + # transitions, world-model proposals, and lifecycle openings. Beside the + # capability stores because the causal ledger reads them together; distinct + # from them because these are facts no other store retains. + return self.root / "plugins" / "evolution_traces.json" + @property def plugin_versions_dir(self) -> Path: # Versioned source snapshots and active pointers for profile-installed plugins. diff --git a/src/leapflow/learning/capability_effect_verifier.py b/src/leapflow/learning/capability_effect_verifier.py new file mode 100644 index 00000000..fcfbaa93 --- /dev/null +++ b/src/leapflow/learning/capability_effect_verifier.py @@ -0,0 +1,316 @@ +"""Verify an acquired capability by its effect, and reclaim what never works. + +Two gaps this closes, both recorded by the EVO-02 experiments: + +**Validation is not fitness (v0.5).** ``PluginValidator`` proves an artifact is +*conformant* -- it parses, imports, satisfies the Protocol, declares valid +metadata. It cannot prove the artifact *works*. Re-resolution afterwards only +proves *declared* fitness: a candidate says it provides the capability and its +declared affordances are present. A structurally perfect adapter targeting the +wrong thing passes both and is still useless. + +**Retirement rode on declared fitness (v0.7).** Because the engine retires +observations whenever a resolution reports the requirement met, a wrongly-selected +tool retired the evidence for its own gap. + +The fix is to make the *observed effect* the arbiter. This module is deliberately +deterministic: the world model decides what to evolve and why; whether it worked is +decided by comparing a declared expectation against an observed outcome. An +``EffectVerdict`` is designed to be fed straight into +``LifecycleGovernor.record_outcome``, so verification reuses the existing trust, +probation and quarantine machinery instead of adding a parallel one -- which is +also what makes reclamation fall out for free: an artifact that keeps failing +verification is quarantined and unregistered by the governor. + +The residual case the governor cannot reach is an artifact that produces *no* +outcomes at all because nothing ever selects it (the over-risk artifact the EVO-02 +episode installed and then refused). :class:`UnselectableArtifactReaper` handles +exactly that, conservatively. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Mapping, Sequence + +from leapflow.domain.capability_requirement import CapabilityRequirement + +logger = logging.getLogger(__name__) + +#: Result keys a tool may use to report what it observably did. This is the whole +#: declaration channel for confirmation: without one of these, a *successful* call is +#: ``unverifiable`` (we do not know whether the effect landed) while a *failed* call +#: still refutes. Several spellings are accepted because the convention post-dates +#: existing tools, and a tool that already says ``observed_effect`` should not have to +#: be rewritten to be verifiable. +OBSERVED_EFFECT_KEYS: tuple[str, ...] = ("observed_effect", "effect") + +#: Reasons a verification can fail, kept as constants so callers can branch on +#: them without matching prose. +NO_OUTCOME = "no_outcome_observed" +EXECUTION_FAILED = "execution_failed" +EFFECT_ABSENT = "expected_effect_absent" +EFFECT_UNREPORTED = "tool_reported_no_effect" +VERIFIED = "effect_observed" +UNVERIFIABLE = "no_expected_effect_declared" + + +def observed_effect_from_result(result: Any) -> str: + """Extract a tool's self-reported observed effect, if it declared one. + + Returns ``""`` when the tool said nothing, which the verifier reads as + *unverifiable* rather than as failure. Deliberately does not synthesise prose from + the rest of the payload: an invented description would be compared against the + teacher's expectation and could confirm an acquisition that never worked. + """ + if not isinstance(result, Mapping): + return "" + for key in OBSERVED_EFFECT_KEYS: + value = result.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +@dataclass(frozen=True) +class EffectVerdict: + """Whether an acquired capability demonstrably did what it promised. + + ``verified is None`` means *unverifiable*, which is deliberately distinct from + ``False``. Two situations produce it: the requirement declared no expected effect + (``UNVERIFIABLE``), or the tool succeeded without reporting what it did + (``EFFECT_UNREPORTED``). Neither is a failure, and treating either as one would + quarantine healthy plugins over missing metadata. + """ + + plugin_id: str + capability: str + verified: bool | None + reason: str + expected_effect: str = "" + observed_effect: str = "" + + @property + def should_record_outcome(self) -> bool: + """Only a decided verdict may drive trust or quarantine.""" + return self.verified is not None + + def to_dict(self) -> dict[str, Any]: + return { + "plugin_id": self.plugin_id, + "capability": self.capability, + "verified": self.verified, + "reason": self.reason, + "expected_effect": self.expected_effect, + "observed_effect": self.observed_effect, + } + + +class CapabilityEffectVerifier: + """Decide whether an acquired capability actually produced its effect. + + Deterministic by design. ``expected_effect`` is a declaration carried on the + requirement (a world-model intent supplies it); the observed outcome comes from + executing the tool. Matching is a containment test over normalized tokens, not + a semantic judgement -- a semantic comparison belongs to the world model, and + putting it here would let a model both propose a capability and certify its own + work. + """ + + def __init__(self, *, min_token_overlap: int = 1) -> None: + self._min_overlap = max(1, int(min_token_overlap)) + + def verify( + self, + requirement: CapabilityRequirement, + outcome: Mapping[str, Any] | None, + *, + plugin_id: str = "", + ) -> EffectVerdict: + """Compare the requirement's declared expectation against one outcome.""" + expected = str(dict(requirement.metadata).get("expected_effect") or "") + capability = requirement.capability + if outcome is None: + return EffectVerdict(plugin_id, capability, None, NO_OUTCOME, expected) + if not outcome.get("ok", False): + # An execution failure is a decided negative regardless of what was + # expected: the capability did not deliver. + return EffectVerdict( + plugin_id, capability, False, EXECUTION_FAILED, expected, + observed_effect_from_result(outcome), + ) + if not expected: + return EffectVerdict(plugin_id, capability, None, UNVERIFIABLE, expected) + + observed = observed_effect_from_result(outcome) + if not observed: + # Absence of evidence, not evidence of absence. The call succeeded and the + # tool simply said nothing about what it did, which is the normal state for + # every handler written before the effect convention existed. Refuting here + # would demote and eventually quarantine healthy plugins for a reporting + # omission -- the exact failure the three-valued verdict exists to prevent. + return EffectVerdict( + plugin_id, capability, None, EFFECT_UNREPORTED, expected + ) + if self._matches(expected, observed): + return EffectVerdict(plugin_id, capability, True, VERIFIED, expected, observed) + # The tool did report an effect and it is not the one that was expected: a + # decided negative, and the case that catches a conformant adapter pointed at + # the wrong thing. + return EffectVerdict(plugin_id, capability, False, EFFECT_ABSENT, expected, observed) + + def _matches(self, expected: str, observed: str) -> bool: + """Token-overlap containment. Empty observation never counts as a match.""" + expected_tokens = _tokens(expected) + observed_tokens = _tokens(observed) + if not expected_tokens or not observed_tokens: + return False + return len(expected_tokens & observed_tokens) >= min( + self._min_overlap, len(expected_tokens) + ) + + +_STOPWORDS = frozenset( + {"the", "a", "an", "is", "are", "was", "were", "in", "on", "at", "to", "of", "and", "it"} +) + + +def _tokens(text: str) -> frozenset[str]: + return frozenset( + token + for token in "".join(ch.lower() if ch.isalnum() else " " for ch in text).split() + if token and token not in _STOPWORDS + ) + + +#: Exclusions that will not be lifted by a change in the environment. An +#: environment-driven exclusion (a missing affordance) can become satisfiable when the +#: app is upgraded; a risk ceiling will not. Matched against the *scorer name* on the +#: excluded score component -- never against its prose, which is free to be reworded. +DURABLE_EXCLUSIONS: tuple[str, ...] = ("risk_cost",) + + +@dataclass(frozen=True) +class ReclamationCandidate: + """A self-acquired artifact that has never been usable.""" + + plugin_id: str + reason: str + resolutions_seen: int + risk_excluded: int + + def to_dict(self) -> dict[str, Any]: + return { + "plugin_id": self.plugin_id, + "reason": self.reason, + "resolutions_seen": self.resolutions_seen, + "risk_excluded": self.risk_excluded, + } + + +class UnselectableArtifactReaper: + """Find self-acquired artifacts that no admissible requirement can select. + + The case the governor cannot reach: an artifact that produces no outcomes + because nothing ever selects it, so it accrues neither trust nor failures and + is never quarantined. The EVO-02 episode installed exactly one -- an over-risk + adapter that stayed registered for the rest of the run, holding a tool name it + could never use. + + The predicate is deliberately conservative, because "unselectable" is only ever + true *relative to the requirements seen so far*: + + * the plugin was installed by self-evolution (the caller supplies that set -- + a hand-installed plugin is never reaped); + * it was **never** selected across every observed resolution; + * every exclusion was a **durable** exclusion -- by default a risk-cap exclusion. + An environment can change and make a tool viable again; a risk ceiling will not + be lifted by the environment, so risk exclusion is the durable kind. Matching is + by the excluded component's **scorer name** (``risk_cost``), not by its prose: + keying off a human-readable reason would silently stop working the moment the + resolver rewords it; + * at least ``min_resolutions`` resolutions were observed, so a single unlucky + requirement cannot condemn an artifact. + + The reaper only *reports*. Disabling or removing a plugin is a governed + mutation and stays with the lifecycle actor. + """ + + def __init__( + self, + *, + min_resolutions: int = 3, + durable_exclusions: Sequence[str] = DURABLE_EXCLUSIONS, + ) -> None: + self._min_resolutions = max(1, int(min_resolutions)) + self._durable = frozenset(str(name) for name in durable_exclusions if str(name)) + + def candidates( + self, + *, + acquired_plugin_ids: Sequence[str], + resolutions: Sequence[Mapping[str, Any]], + ) -> tuple[ReclamationCandidate, ...]: + """Return artifacts that every observed resolution refused on risk grounds. + + ``resolutions`` entries are expected to expose ``selected_plugin`` and a + per-candidate ``exclusions`` mapping of ``plugin_id -> excluded scorer names``. + """ + acquired = {str(pid) for pid in acquired_plugin_ids if str(pid)} + if not acquired or len(resolutions) < self._min_resolutions: + return () + + seen: dict[str, int] = {pid: 0 for pid in acquired} + risk_excluded: dict[str, int] = {pid: 0 for pid in acquired} + selected_ever: set[str] = set() + + for resolution in resolutions: + selected = str(resolution.get("selected_plugin") or "") + if selected in acquired: + selected_ever.add(selected) + exclusions = dict(resolution.get("exclusions") or {}) + for plugin_id in acquired: + reasons = exclusions.get(plugin_id) + if reasons is None: + continue + seen[plugin_id] += 1 + if any(str(reason) in self._durable for reason in reasons): + risk_excluded[plugin_id] += 1 + + found: list[ReclamationCandidate] = [] + for plugin_id in sorted(acquired): + if plugin_id in selected_ever: + continue + observed = seen[plugin_id] + if observed < self._min_resolutions: + continue + # Every single exclusion must be the durable (risk) kind. + if risk_excluded[plugin_id] != observed: + continue + found.append( + ReclamationCandidate( + plugin_id=plugin_id, + reason="never selected; durably excluded in every observed resolution", + resolutions_seen=observed, + risk_excluded=risk_excluded[plugin_id], + ) + ) + return tuple(found) + + +__all__ = [ + "DURABLE_EXCLUSIONS", + "EFFECT_ABSENT", + "EFFECT_UNREPORTED", + "EXECUTION_FAILED", + "NO_OUTCOME", + "OBSERVED_EFFECT_KEYS", + "UNVERIFIABLE", + "VERIFIED", + "CapabilityEffectVerifier", + "EffectVerdict", + "ReclamationCandidate", + "UnselectableArtifactReaper", + "observed_effect_from_result", +] diff --git a/src/leapflow/learning/capability_gap_detector.py b/src/leapflow/learning/capability_gap_detector.py index 9f599362..4f5197bc 100644 --- a/src/leapflow/learning/capability_gap_detector.py +++ b/src/leapflow/learning/capability_gap_detector.py @@ -12,10 +12,23 @@ from typing import Any from leapflow.domain.capability_requirement import CapabilityRequirement +from leapflow.domain.evolution_intent import ( + MODEL_AUTHORED_RISK_CEILING, + WORLD_MODEL_INTENT, + EvolutionIntent, +) from leapflow.domain.plugin_proposal import GapEvidence, PluginProposal, ProposedToolSpec, RiskLevel _SAFE_IDENTIFIER = re.compile(r"[^a-z0-9_]+") +# Requirement origins a declared-evidence payload may claim. Anything else falls +# back to ``environment_probe`` so a malformed payload cannot smuggle in an +# origin the domain layer does not recognise. +_DECLARED_ORIGINS = frozenset( + {"unknown_tool", "explicit_request", "environment_probe", "task_contract", "world_model"} +) +_DEFAULT_DECLARED_ORIGIN = "environment_probe" + def _slug(value: str, *, fallback: str) -> str: text = str(value or "").strip().lower().replace("-", " ").replace(".", " ") @@ -66,6 +79,65 @@ def proposal_from_unknown_tool( proposed_tools=(proposed_tool,), ) + def proposal_from_evolution_intent( + self, + intent: EvolutionIntent, + *, + risk_ceiling: RiskLevel = MODEL_AUTHORED_RISK_CEILING, + ) -> PluginProposal: + """Create a side-effect-free proposal from a world-model intent. + + This is how a world-model hypothesis reaches the surface that actually + leads to governed acquisition: the same ``PluginProposal`` shape that + ``self_management.plugin_propose`` produces, so it flows on through + ``plugin_generate`` (validated code, no install) and ``plugin_install`` + (approval-gated). Creating a proposal mutates nothing. + + The proposal's risk level is the *clamped* ceiling, never the level the + authoring model asked for; the original request is preserved in the + evidence metadata for audit. + """ + effective = intent.effective_risk_ceiling(risk_ceiling) + metadata: dict[str, Any] = { + "intent_id": intent.intent_id, + "capability": intent.capability, + "confidence": intent.confidence, + } + for key, value in ( + ("target_affordance", intent.target_affordance), + ("expected_effect", intent.expected_effect), + ("rationale", intent.rationale), + ): + if value: + metadata[key] = value + if effective != str(intent.max_risk_level): + metadata["requested_max_risk_level"] = str(intent.max_risk_level) + if intent.evidence_ids: + metadata["evidence_ids"] = ",".join(intent.evidence_ids) + + evidence = GapEvidence.create( + WORLD_MODEL_INTENT, + intent.hypothesis, + confidence=intent.confidence, + metadata=metadata, + ) + tool_name = _slug(intent.capability, fallback="generated_tool") + mutates = effective in {"high", "mutating", "external"} + proposed_tool = ProposedToolSpec( + name=tool_name, + description=intent.expected_effect or intent.hypothesis, + risk_level=effective, # type: ignore[arg-type] + mutates_state=mutates, + ) + return PluginProposal.create( + plugin_id=_slug(f"{tool_name}_plugin", fallback="generated_tool_plugin"), + capability_summary=intent.hypothesis, + gap_type="tool_plugin", + risk_level=effective, # type: ignore[arg-type] + evidence=(evidence,), + proposed_tools=(proposed_tool,), + ) + def proposal_from_capability_request( self, requested_capability: str, @@ -116,22 +188,42 @@ def requirements_from_tool_results( *, min_count: int = 1, ) -> tuple[CapabilityRequirement, ...]: - """Aggregate unknown-tool evidence into reviewable capability needs. + """Aggregate structured evidence into reviewable capability needs. + + This is the observation-only bridge from runtime evidence to adaptive + resolution. It creates no code, performs no install, and never infers a + capability name from user text. + + Two evidence shapes are recognised, both declaration-driven: - This is the observation-only bridge from failed tool calls to adaptive - resolution. It creates no code, performs no install, and does not infer - capability names from user text; it only reflects the structured - ``original_tool_name`` emitted by the tool registry. + * ``unknown_tool`` results, bucketed by the structured + ``original_tool_name`` emitted by the tool registry (unchanged). + * any other ``error_type`` that **declares** its ``capability``. Without a + declared capability the payload is ignored, which keeps the "never + infer capability from text" rule intact while letting environment- and + world-model-derived evidence reach the same governed pipeline. + + Widening the accepted evidence set is the job of + ``CapabilityEvidenceClassifier``; this method is what turns the admitted + evidence into requirements. Both halves are required -- admitting an + evidence kind whose payload cannot become a requirement would persist + observations that silently never produce one. """ - buckets: dict[str, list[Mapping[str, Any]]] = {} + unknown_buckets: dict[str, list[Mapping[str, Any]]] = {} + declared_buckets: dict[tuple[str, str], list[Mapping[str, Any]]] = {} for result in results: - if result.get("error_type") != "unknown_tool": + kind = str(result.get("error_type") or "") + if kind == "unknown_tool": + key = str(result.get("original_tool_name") or "unknown_tool") + unknown_buckets.setdefault(key, []).append(result) continue - key = str(result.get("original_tool_name") or "unknown_tool") - buckets.setdefault(key, []).append(result) + capability = str(result.get("capability") or "").strip() + if not kind or not capability: + continue + declared_buckets.setdefault((kind, capability), []).append(result) requirements: list[CapabilityRequirement] = [] - for key, bucket in sorted(buckets.items()): + for key, bucket in sorted(unknown_buckets.items()): if len(bucket) < min_count: continue latest = bucket[-1] @@ -151,8 +243,65 @@ def requirements_from_tool_results( requirement_id=f"req-unknown-tool-{_slug(key, fallback='generated_tool')}", ) ) + for (kind, capability), bucket in sorted(declared_buckets.items()): + if len(bucket) < min_count: + continue + requirements.append( + self._requirement_from_declared(kind, capability, bucket) + ) return tuple(requirements) + def _requirement_from_declared( + self, + kind: str, + capability: str, + bucket: Sequence[Mapping[str, Any]], + ) -> CapabilityRequirement: + """Build a requirement from declared (non-unknown-tool) evidence. + + Every field is read from the payload's declarations; nothing is inferred. + """ + latest = bucket[-1] + origin = str(latest.get("origin") or "") + if origin not in _DECLARED_ORIGINS: + origin = _DEFAULT_DECLARED_ORIGIN + evidence = str(latest.get("evidence") or latest.get("recovery_hint") or "") + metadata: dict[str, Any] = { + "evidence_kind": kind, + "occurrences": len(bucket), + } + # Propagated declarations. ``target_affordance`` and ``expected_effect`` + # are what tell a later generation step *what to build against* and *how to + # verify it*; dropping them would leave the requirement unactionable. + for field_name in ( + "failure_code", + "recovery_hint", + "confidence", + "intent_id", + "target_affordance", + "expected_effect", + "requested_max_risk_level", + ): + value = latest.get(field_name) + if value not in (None, ""): + metadata[field_name] = value + suggestions = latest.get("suggestions") or () + if suggestions: + metadata["suggestions"] = ",".join(str(item) for item in list(suggestions)[:5]) + kwargs: dict[str, Any] = { + "evidence": evidence, + "metadata": metadata, + "requirement_id": str(latest.get("requirement_id") or "") + or f"req-{_slug(kind, fallback='evidence')}-{_slug(capability, fallback='capability')}", + } + max_risk = latest.get("max_risk_level") + if max_risk: + kwargs["max_risk_level"] = max_risk + required = latest.get("required_platform_capabilities") + if required: + kwargs["required_platform_capabilities"] = list(required) + return CapabilityRequirement.create(capability, origin, **kwargs) # type: ignore[arg-type] + def proposals_from_tool_results( self, results: Sequence[Mapping[str, Any]], diff --git a/src/leapflow/learning/capability_observation.py b/src/leapflow/learning/capability_observation.py index 162d7bb4..3524feab 100644 --- a/src/leapflow/learning/capability_observation.py +++ b/src/leapflow/learning/capability_observation.py @@ -27,11 +27,14 @@ class CapabilityEvidenceClassifier: The shipped observation layer hard-codes ``error_type == "unknown_tool"``, which is blind to a structural environment change under a still-present tool. This classifier makes the accepted ``error_type`` set explicit and - configurable so an environment-aware source (e.g. interface-drift / - affordance-loss signals) can feed the same governed pipeline, while the - default set preserves today's behaviour exactly. The accepted set is meant to - be driven by ``environment_adaptation.accepted_evidence_kinds`` config; it is - never inferred from natural-language text. + configurable so an environment-aware source (interface-drift / affordance-loss + signals) or the world-model teacher (``world_model_intent``) can feed the same + governed pipeline, while the default set preserves today's behaviour exactly. + + The accepted set is driven by the ``accepted_evidence_kinds`` setting (see + :meth:`from_settings`); it is never inferred from natural-language text. + Widening it adds a *trigger*, never a permission: every admitted kind still + traverses resolution, risk classification, approval, validation, and trust. """ accepted: frozenset[str] = DEFAULT_ACCEPTED_EVIDENCE @@ -43,6 +46,15 @@ def from_kinds(cls, kinds: Iterable[str] | None = None) -> "CapabilityEvidenceCl return cls() return cls(accepted=frozenset(str(kind) for kind in kinds if str(kind))) + @classmethod + def from_settings(cls, settings: Any) -> "CapabilityEvidenceClassifier": + """Build from a Settings-like object's ``accepted_evidence_kinds``. + + Returns the default (``unknown_tool`` only) when the setting is absent or + empty, so an operator must opt in before any new trigger becomes live. + """ + return cls.from_kinds(getattr(settings, "accepted_evidence_kinds", None)) + def accepts(self, result: Mapping[str, Any] | None) -> bool: return isinstance(result, Mapping) and str(result.get("error_type") or "") in self.accepted @@ -193,6 +205,39 @@ def requirements( ] return self._detector.requirements_from_tool_results(results, min_count=1) + def resolve_capability( + self, capability: str, *, reason: str = "", limit: int = 50 + ) -> tuple[str, ...]: + """Retire observations whose capability gap is now satisfied. + + Without this the observation lifecycle is write-only: ``unresolved()`` + filters on ``status == "open"``, so evidence that motivated a capability + which now resolves keeps being reported, and any consumer sizing work from + it would re-propose capabilities the system already has. + + Matching is done by running the same detector used to derive requirements, + so an observation is retired only when it genuinely maps to the resolved + capability -- never by string-matching the raw payload. Returns the ids of + the observations retired. + """ + target = str(capability or "").strip() + if not target: + return () + retired: list[str] = [] + for record in self._store.unresolved(min_count=1, limit=limit): + observation_id = str(record.get("observation_id") or "") + if not observation_id: + continue + derived = self._detector.requirements_from_tool_results( + [record.get("result") or {}], min_count=1 + ) + if any(requirement.capability == target for requirement in derived): + if self._store.mark_status( + observation_id, "resolved", reason=reason or f"{target} resolved" + ): + retired.append(observation_id) + return tuple(retired) + __all__ = [ "CapabilityEvidenceClassifier", diff --git a/src/leapflow/learning/outcome_governance_feed.py b/src/leapflow/learning/outcome_governance_feed.py new file mode 100644 index 00000000..c88744fe --- /dev/null +++ b/src/leapflow/learning/outcome_governance_feed.py @@ -0,0 +1,189 @@ +"""Feed execution outcomes into lifecycle governance without touching the hot path. + +Trust already accrues in production: ``TurnUsageTracker.record_tool_call`` forwards +to ``PluginUsageTracker.record``, which resolves tool -> plugin and calls +``record_success`` / ``record_failure``. So promotion and demotion work on live +traffic. **Quarantine does not** -- ``LifecycleGovernor`` had no feed, so a plugin +could fail indefinitely without ever being disabled. + +The wiring has to respect two constraints that pull against each other: + +* ``PluginUsageTracker.record`` is documented as a ``<1us`` hot path, and plugin + governance is required to be cold-path -- a governance feature that measurably + slows an ordinary turn is a defect in the feature. +* Governance is async and I/O-bound: it writes lifecycle status, appends an outcome + record, and may call the lifecycle actor to disable a plugin. + +So this splits in two. On the hot path :class:`QuarantineCandidateTracker` keeps one +integer per plugin and does nothing else -- no I/O, no awaiting, no allocation +beyond a dict entry. Crossing the threshold only *marks* a candidate. The actual +governance runs later, on a cold path, via :func:`drain_quarantine_candidates`. + +The consequence is explicit and worth stating: quarantine is **deferred**, not +immediate. A plugin that crosses the threshold mid-session keeps serving until the +next drain. That is the deliberate trade for not putting I/O in the hot path; the +per-turn trust demotion still applies immediately, so a failing plugin is already +being down-ranked by the resolver while it waits. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Mapping, Sequence + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class QuarantineCandidate: + """A plugin whose consecutive-failure streak crossed the threshold.""" + + plugin_id: str + tool_name: str + failure_streak: int + + def to_dict(self) -> dict[str, Any]: + return { + "plugin_id": self.plugin_id, + "tool_name": self.tool_name, + "failure_streak": self.failure_streak, + } + + +class QuarantineCandidateTracker: + """Hot-path-safe consecutive-failure counter. + + ``record`` is called once per tool execution, so it must stay trivial: one dict + lookup and an integer update. It never performs I/O and never awaits. + """ + + def __init__(self, *, quarantine_after: int = 3) -> None: + self._threshold = max(1, int(quarantine_after)) + self._streaks: dict[str, int] = {} + self._candidates: dict[str, QuarantineCandidate] = {} + + @property + def threshold(self) -> int: + return self._threshold + + def record(self, plugin_id: str, tool_name: str, ok: bool) -> bool: + """Note one outcome. Returns True when this crossed the threshold. + + A success resets the streak, which is what makes an intermittently-failing + plugin survive: only *consecutive* failures quarantine. + """ + if not plugin_id: + return False + if ok: + self._streaks.pop(plugin_id, None) + self._candidates.pop(plugin_id, None) + return False + streak = self._streaks.get(plugin_id, 0) + 1 + self._streaks[plugin_id] = streak + if streak < self._threshold: + return False + self._candidates[plugin_id] = QuarantineCandidate(plugin_id, tool_name, streak) + return True + + def candidates(self) -> tuple[QuarantineCandidate, ...]: + return tuple(self._candidates.values()) + + def clear(self, plugin_id: str = "") -> None: + """Drop one candidate, or all of them.""" + if plugin_id: + self._candidates.pop(plugin_id, None) + self._streaks.pop(plugin_id, None) + return + self._candidates.clear() + self._streaks.clear() + + def pending(self) -> int: + return len(self._candidates) + + +async def drain_quarantine_candidates( + tracker: QuarantineCandidateTracker, + governor: Any, + *, + proposal_ids: Mapping[str, str] | None = None, +) -> tuple[dict[str, Any], ...]: + """Run lifecycle governance for every marked candidate. Cold path only. + + Each candidate is reported to ``LifecycleGovernor.record_outcome`` as a failure, + which applies trust, updates lifecycle status and (at or past the governor's own + threshold) disables the plugin. Candidates are cleared as they are handled, so a + second drain is a no-op rather than a double punishment. + + Failures are contained per candidate: a store error on one plugin must not stop + the others from being governed. + """ + handled: list[dict[str, Any]] = [] + ids = dict(proposal_ids or {}) + for candidate in tracker.candidates(): + try: + result = await governor.record_outcome( + proposal_id=ids.get(candidate.plugin_id, ""), + plugin_id=candidate.plugin_id, + tool_name=candidate.tool_name, + ok=False, + failure_class="consecutive_failures", + ) + handled.append( + { + "plugin_id": candidate.plugin_id, + "failure_streak": candidate.failure_streak, + "action": getattr(result, "action", ""), + "trust_level": getattr(result, "trust_level", ""), + } + ) + except Exception: # noqa: BLE001 - governance must not break the drain + logger.debug( + "quarantine drain failed for %s", candidate.plugin_id, exc_info=True + ) + continue + finally: + tracker.clear(candidate.plugin_id) + return tuple(handled) + + +#: Empty means unrestricted: any requirement origin may drive acquisition, which is +#: the shipped behaviour. Populating it restricts authority to the listed origins. +DEFAULT_AUTHORISING_ORIGINS: tuple[str, ...] = () + + +def origin_may_authorise(origin: str, authorising_origins: Sequence[str] | None) -> bool: + """Whether a requirement of this origin may drive an acquisition. + + This is the executable form of "all self-evolution's first driver is the world + model": set ``authorising_origins = ("world_model",)`` and a requirement raised + by any other path can still be *recorded and resolved*, but can no longer + authorise acquiring new code. + + Kept permissive by default so enabling it is a deliberate operator decision + rather than a silent behaviour change. + """ + if not authorising_origins: + return True + return str(origin) in {str(item) for item in authorising_origins} + + +def filter_authorised( + requirements: Sequence[Any], authorising_origins: Sequence[str] | None +) -> tuple[Any, ...]: + """Keep only the requirements permitted to drive acquisition.""" + return tuple( + requirement + for requirement in requirements + if origin_may_authorise(getattr(requirement, "origin", ""), authorising_origins) + ) + + +__all__ = [ + "DEFAULT_AUTHORISING_ORIGINS", + "QuarantineCandidate", + "QuarantineCandidateTracker", + "drain_quarantine_candidates", + "filter_authorised", + "origin_may_authorise", +] diff --git a/src/leapflow/learning/plugin_generator.py b/src/leapflow/learning/plugin_generator.py index 6de91d9c..720f1a8c 100644 --- a/src/leapflow/learning/plugin_generator.py +++ b/src/leapflow/learning/plugin_generator.py @@ -347,6 +347,12 @@ def build_generation_prompt(self, request: PluginGenerationRequest) -> str: 6. Import from: from leapflow.plugins.protocol import ToolMetadata, ToolPlugin 7. NO dangerous operations (no eval/exec/os.system/file deletion at import time) 8. All handlers are async functions taking **kwargs and returning a dict +9. On success, every handler MUST report what it observably did in an "effect" key, + phrased in the same terms as the requirement above (e.g. + {{"ok": True, "effect": "the reply was delivered to the thread"}}). This is how the + framework confirms the capability actually worked rather than merely returned; a + handler that omits it can never be verified, only refuted. Describe the observed + outcome, never restate the intent. Example structure: ```python @@ -364,7 +370,8 @@ def bind_runtime(self, **deps: Any) -> None: pass @property def tools(self) -> list[ToolMetadata]: return [ToolMetadata(name="...", description="...", parameters_schema={{"type":"object","properties":{{}}}}, handler=self._handler, x_leapflow={{"category":"custom","risk_level":"read_only"}})] - async def _handler(self, **kwargs: Any) -> dict: return {{"ok": True}} + async def _handler(self, **kwargs: Any) -> dict: + return {{"ok": True, "effect": ""}} plugin = MyPlugin() ``` diff --git a/src/leapflow/learning/plugin_stats.py b/src/leapflow/learning/plugin_stats.py index 796f7ea1..7b2ffd8d 100644 --- a/src/leapflow/learning/plugin_stats.py +++ b/src/leapflow/learning/plugin_stats.py @@ -12,8 +12,12 @@ from dataclasses import dataclass from typing import Any, Dict, Optional +import logging + from leapflow.learning.plugin_trust import PluginTrustLedger +logger = logging.getLogger(__name__) + @dataclass(frozen=True, slots=True) class PluginUsageSample: @@ -56,6 +60,16 @@ def set_trust_ledger(self, ledger: PluginTrustLedger) -> None: """Inject the trust ledger for automatic trust forwarding.""" self._trust_ledger = ledger + def set_quarantine_tracker(self, tracker: Any) -> None: + """Inject the consecutive-failure tracker that feeds cold-path quarantine. + + Trust demotion has always been immediate here; quarantine had no feed at all, + so a plugin could fail indefinitely without being disabled. The tracker keeps + one integer per plugin and is drained by the cold-path co-evolution sweep -- + governance work itself must never run on this path. + """ + self._quarantine_tracker = tracker + def record(self, tool_name: str, ok: bool, duration_ms: float) -> None: """Called by TurnUsageTracker forward. Must be fast (<1μs hot path).""" sample = PluginUsageSample(time.time(), ok, duration_ms) @@ -68,6 +82,20 @@ def record(self, tool_name: str, ok: bool, duration_ms: float) -> None: self._trust_ledger.record_success(plugin_id) else: self._trust_ledger.record_failure(plugin_id) + # Streak bookkeeping only: a dict lookup and an integer update. The + # governance it may trigger runs later, on the sweep's cold path. + # + # Effect verification is deliberately *not* recorded here: this path + # receives only ``ok``, and a tool's observed effect lives in its result + # payload. The engine's result-observation path records outcomes, so + # doing it here as well would double-count and would grade every + # success as unverifiable. + tracker = getattr(self, "_quarantine_tracker", None) + if tracker is not None: + try: + tracker.record(plugin_id, tool_name, ok) + except Exception: # noqa: BLE001 - never fail a tool call on it + logger.debug("quarantine streak not recorded", exc_info=True) def stats_for_plugin(self, plugin_id: str) -> Optional[PluginStats]: """Aggregate stats across all tools owned by a plugin.""" diff --git a/src/leapflow/learning/plugin_trust.py b/src/leapflow/learning/plugin_trust.py index a983bc95..7c7c2714 100644 --- a/src/leapflow/learning/plugin_trust.py +++ b/src/leapflow/learning/plugin_trust.py @@ -61,6 +61,18 @@ def level(self, plugin_id: str) -> PluginTrustLevel: return PluginTrustLevel.DRAFT return self._levels.get(plugin_id, PluginTrustLevel.DRAFT) + def is_frozen(self, plugin_id: str) -> bool: + """Whether the plugin is permanently frozen by an internal defect. + + A frozen plugin reports ``DRAFT``, but ``DRAFT`` alone cannot distinguish + "new and unproven" from "permanently disqualified" -- so consumers that + must exclude rather than merely down-rank need this predicate. Selection + is the case in point: the resolver's trust dimension only *scores*, so + without an explicit frozen check a frozen-but-registered plugin stays + eligible whenever governance has not also unregistered it. + """ + return plugin_id in self._frozen + def record_success(self, plugin_id: str) -> None: """Record a successful execution — accrue trust, may promote.""" if plugin_id in self._frozen: diff --git a/src/leapflow/learning/world_model_driver.py b/src/leapflow/learning/world_model_driver.py new file mode 100644 index 00000000..8d293a75 --- /dev/null +++ b/src/leapflow/learning/world_model_driver.py @@ -0,0 +1,266 @@ +"""The world model as the first driver of capability self-evolution. + +``TrajectoryGrader.grade_and_propose`` can emit an :class:`EvolutionIntent`, and +the observation pipeline can turn a declared intent into a governed +``CapabilityRequirement``. Nothing joined the two, so the world model could form a +capability hypothesis that no part of the system ever received. This driver is +that join, and it is deliberately the *only* one. + +Where it runs, and why that is safe: + +* **Cold path, once per episode.** It is invoked at the session-end learning + boundary, after a trajectory is flushed -- never inside a turn. The teacher's own + ``grading`` budget pool bounds how often it can spend an LLM call, so making the + world model the first driver adds no per-turn cost. +* **Privileged context, not privileged authority.** The teacher sees the whole + trajectory with actual outcomes (hindsight the acting policy never had), which is + what lets it notice a capability was *missing* rather than merely used badly. It + still only proposes: each intent is written as ordinary structured evidence and + must pass the classifier, the detector, resolution, risk classification, + approval, validation and trust exactly like an ``unknown_tool`` signal. +* **Opt-in.** Admission is decided by ``CapabilityEvidenceClassifier``. Until an + operator adds ``world_model_intent`` to ``accepted_evidence_kinds``, intents are + reported as *proposed but not admitted* and change nothing. The driver never + writes around that gate. +* **Clamped.** Every intent is rendered with an explicit ``risk_ceiling``, so a + model cannot widen the risk cap of the capability it is asking for. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Mapping, Protocol, Sequence, runtime_checkable + +from leapflow.domain.capability_requirement import CapabilityRequirement +from leapflow.domain.evolution_intent import ( + MODEL_AUTHORED_RISK_CEILING, + EvolutionIntent, +) +from leapflow.domain.plugin_proposal import RiskLevel + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class CapabilityGapTeacher(Protocol): + """A hindsight evaluator that can also propose capability gaps. + + Structural rather than a concrete import so the driver does not bind the + learning layer to ``world_model``, and so a recorded or stub teacher can be + substituted in tests and experiments. + """ + + async def grade_and_propose(self, trajectory: list[dict], goal: str = "") -> Any: + """Return an object exposing ``grades`` and ``intents``.""" + ... + + +@runtime_checkable +class EvidenceIntake(Protocol): + """The governed intake an intent must pass through.""" + + def observe_result( + self, result: Mapping[str, Any] | None, **kwargs: Any + ) -> dict[str, Any] | None: + """Persist admitted evidence; return ``None`` when the gate rejects it.""" + ... + + def requirements( + self, *, min_count: int = 1, limit: int = 50 + ) -> tuple[CapabilityRequirement, ...]: + """Derive requirements from admitted evidence.""" + ... + + +@dataclass(frozen=True) +class WorldModelDriveResult: + """What one world-model-driven evolution pass produced. + + ``proposed`` counts every intent the teacher formed; ``admitted`` counts those + the evidence gate accepted. The two differ whenever the operator has not opted + in, which is the normal default -- so a non-zero ``proposed`` with an empty + ``admitted`` is a correct, quiet outcome, not a failure. + """ + + grades: tuple[Any, ...] = () + intents: tuple[EvolutionIntent, ...] = () + admitted_observation_ids: tuple[str, ...] = () + requirements: tuple[CapabilityRequirement, ...] = field(default_factory=tuple) + + @property + def proposed(self) -> int: + return len(self.intents) + + @property + def admitted(self) -> int: + return len(self.admitted_observation_ids) + + def to_dict(self) -> dict[str, Any]: + return { + "graded_actions": len(self.grades), + "proposed": self.proposed, + "admitted": self.admitted, + "capabilities": sorted({r.capability for r in self.requirements}), + } + + +class WorldModelEvolutionDriver: + """Turn hindsight capability hypotheses into governed requirements.""" + + def __init__( + self, + *, + teacher: CapabilityGapTeacher, + intake: EvidenceIntake, + risk_ceiling: RiskLevel = MODEL_AUTHORED_RISK_CEILING, + source: str = "world_model", + ) -> None: + self._teacher = teacher + self._intake = intake + self._risk_ceiling = risk_ceiling + self._source = source + + async def drive( + self, + trajectory: Sequence[Mapping[str, Any]], + goal: str = "", + *, + environment: Any = None, + session_id: str = "", + turn_id: str = "", + workspace_root: str = "", + ) -> WorldModelDriveResult: + """Grade the episode, then submit any capability gap it revealed. + + Returns an empty result rather than raising: this runs on a learning + boundary, and a failure to learn must never fail the session that produced + the trajectory. + """ + if not trajectory: + return WorldModelDriveResult() + try: + verdict = await self._teacher.grade_and_propose(list(trajectory), goal) + except Exception: # noqa: BLE001 - teacher is advisory; never fail the session + logger.debug("world_model_driver: teacher failed", exc_info=True) + return WorldModelDriveResult() + + grades = tuple(getattr(verdict, "grades", ()) or ()) + intents = tuple(getattr(verdict, "intents", ()) or ()) + if not intents: + return WorldModelDriveResult(grades=grades) + + admitted: list[str] = [] + for intent in intents: + try: + record = self._intake.observe_result( + intent.to_observation_result(risk_ceiling=self._risk_ceiling), + environment=environment, + source=self._source, + session_id=session_id, + turn_id=turn_id, + workspace_root=workspace_root, + ) + except (OSError, RuntimeError, TypeError, ValueError, AttributeError): + logger.debug("world_model_driver: intake rejected an intent", exc_info=True) + continue + if record is not None: + observation_id = str(record.get("observation_id") or "") + if observation_id: + admitted.append(observation_id) + + requirements: tuple[CapabilityRequirement, ...] = () + if admitted: + try: + requirements = self._intake.requirements(min_count=1) + except (OSError, RuntimeError, TypeError, ValueError, AttributeError): + logger.debug("world_model_driver: requirement derivation failed", exc_info=True) + if intents and not admitted: + logger.debug( + "world_model_driver: %d intent(s) proposed but not admitted; add " + "'world_model_intent' to accepted_evidence_kinds to enable", + len(intents), + ) + result = WorldModelDriveResult( + grades=grades, + intents=intents, + admitted_observation_ids=tuple(admitted), + requirements=requirements, + ) + self._trace_drive(result) + return result + + def _trace_drive(self, result: WorldModelDriveResult) -> None: + """Emit what the teacher concluded, admitted or not. + + The highest-value probe in the system, because of the case it is the only + record of: an intent that was *proposed and not admitted* writes no + observation, so it exists nowhere durable and vanishes with the process. The + board would otherwise show a silent, idle pipeline while the world model was + in fact proposing on every session -- indistinguishable from a model that had + nothing to say. + + Not admitting is a legitimate quiet outcome, not a failure: the evidence kind + simply is not in ``accepted_evidence_kinds``. The trace says which it was so + a reader can tell "switched off" from "nothing happening". + """ + try: + from leapflow.domain.evolution_trace import EvolutionStage + from leapflow.telemetry.evolution_tap import emit_trace, is_enabled + + if not is_enabled(): + return + intents = result.intents + admitted = result.admitted_observation_ids + emit_trace( + EvolutionStage.OBSERVE, + "world_model_drive", + correlation={ + "intent_ids": ",".join( + str(getattr(i, "intent_id", "")) for i in intents + ), + }, + summary=( + f"teacher proposed {len(intents)}, admitted {len(admitted)}" + if intents + else "teacher proposed nothing" + ), + detail={ + # The model's own hypothesis, rationale, expected effect and + # confidence -- the only structured answer to "why should this + # evolve" that exists anywhere. + "intents": [self._intent_detail(i) for i in intents], + "admitted_observation_ids": list(admitted), + "graded": len(result.grades), + "requirements": len(result.requirements), + "not_admitted_reason": ( + "world_model_intent is not in accepted_evidence_kinds" + if intents and not admitted + else "" + ), + }, + ) + except Exception: # noqa: BLE001 - the teacher is advisory; telemetry more so + logger.debug("world_model_driver: evolution trace failed", exc_info=True) + + @staticmethod + def _intent_detail(intent: Any) -> dict[str, Any]: + """Serialise an intent defensively -- a teacher-authored object may be partial.""" + to_dict = getattr(intent, "to_dict", None) + if callable(to_dict): + try: + return dict(to_dict()) + except Exception: # noqa: BLE001 + pass + return { + key: getattr(intent, key, "") + for key in ("intent_id", "capability", "hypothesis", "confidence") + } + + +__all__ = [ + "CapabilityGapTeacher", + "EvidenceIntake", + "WorldModelDriveResult", + "WorldModelEvolutionDriver", +] diff --git a/src/leapflow/monitor/__init__.py b/src/leapflow/monitor/__init__.py index 513aabff..90d6ddc0 100644 --- a/src/leapflow/monitor/__init__.py +++ b/src/leapflow/monitor/__init__.py @@ -9,6 +9,7 @@ from leapflow.monitor.capability_adaptation_producer import CapabilityAdaptationProducer from leapflow.monitor.event_bridge import EventBridge +from leapflow.monitor.evolution_producer import EvolutionProducer from leapflow.monitor.finding_store import FindingStore from leapflow.monitor.manager import EmitFn, MonitorManager from leapflow.monitor.plugin_health_producer import PluginHealthProducer @@ -39,6 +40,7 @@ __all__ = [ "CapabilityAdaptationProducer", "EventBridge", + "EvolutionProducer", "EVENT_FINDING", "EVENT_WATCH_STATE", "EVENT_ERROR", diff --git a/src/leapflow/monitor/evolution_producer.py b/src/leapflow/monitor/evolution_producer.py new file mode 100644 index 00000000..e5b93126 --- /dev/null +++ b/src/leapflow/monitor/evolution_producer.py @@ -0,0 +1,1397 @@ +"""Monitor producer for framework self-evolution transparency. + +Domain: ``framework_evolution``. Answers two questions the existing views cannot: + +**What is the framework right now?** The plugin roster with each plugin's fiber +state and trust, the capability topology, and the tool-name conflicts. Read live +from ``get_registry()`` and the trust ledger every cycle, never from +documentation or a cached catalog -- reporting LeapFlow's own composition from +anything but the running registry is how a board ends up describing capabilities +the process does not have. + +**Is the evolution pipeline actually flowing?** ``_reachability()`` walks the +pipeline segment by segment and reports the runtime evidence for each. This +exists because a whole tier of trust/probation/quarantine machinery was once +reachable from nothing in production, and no test or view could see it -- it was +found by auditing which modules had no references outside themselves. A segment +with no evidence is reported as ``no_evidence`` with the next step to take, and a +segment whose source cannot be read is ``unverifiable``. Neither is ever reported +as working: the presence of a module is not evidence that anything calls it. + +This producer owns no causal history of its own: episodes are rebuilt on demand by +``EvolutionLedger`` from the decision and observation records the system already +keeps, so the timeline costs no probe and no new schema. When those records cannot +be read, ``episodes`` is empty and ``degraded`` says why -- which is what lets the +panel stay useful on a profile that has never evolved anything. +""" + +from __future__ import annotations + +import logging +from typing import Any, Mapping, Sequence + +from leapflow.domain.evolution_trace import ABORTED, REOPENED, RESOLVED, STILL_OPEN +from leapflow.monitor.types import Evidence, Finding, ProducerContext, Severity, SuggestedAction + +logger = logging.getLogger(__name__) + +# Payload bounds. The reply travels as one JSON-RPC frame (the coordinator trims +# oldest-first at the transport), so a producer that does not bound its own +# payload pushes still-current findings out of the batch. +_MAX_ROSTER = 60 +_MAX_CONFLICTS = 40 +_MAX_TOPOLOGY_NODES = 240 +_MAX_TOPOLOGY_EDGES = 400 +_MAX_CAPABILITY_MAP = 200 +_MAX_EPISODES = 20 +_MAX_TRACES = 40 + +# Trust vocabulary. ``DRAFT`` alone cannot distinguish "new and unproven" from +# "permanently disqualified by an internal defect", so the semantic class is +# reported alongside the level. +_TRUST_CLASS = { + "DRAFT": "new_unproven", + "CANDIDATE": "accruing", + "VERIFIED": "accruing", + "PRODUCTION": "trusted", +} + +#: Rendered as a table cell, so it must be a translatable key like every other +#: closed vocabulary here. ``frozen`` is lower-case for that reason -- the trust +#: *level* is an upper-case enum name, but this is a semantic class, and mixing the +#: two casings in one column made it read like two different kinds of value. +_TRUST_CLASS_FROZEN = "frozen" + +#: Booleans reach the board as untranslatable ``true``/``false``: the client's value +#: translator passes non-strings straight through. Closed vocabularies are emitted as +#: keys instead, so every locale renders a word rather than a JSON literal. +_YES = "yes" +_NO = "no" + +# ── Localisation boundary ──────────────────────────────────────────────── +# +# The client translates every string table cell through a dictionary with a raw +# fallback, so what this producer emits decides what can be localised. The line is +# drawn deliberately: +# +# * **Closed vocabularies and segment labels are keys** -- statuses, trust classes, +# fiber states, gap closures, verification tiers, drivers, actions, yes/no. Each +# has a translation in every shipped locale, asserted by +# ``test_every_closed_vocabulary_the_payload_emits_is_translated``. +# * **Operator instructions stay in English.** ``evidence`` and ``next_step`` embed +# literal commands (``leap config set ...``) and symbol names that must not be +# translated to remain runnable. A half-translated sentence wrapped around an +# English command is harder to act on than a consistent English one, so these are +# left whole rather than fragmented into interpolated keys. +# * **Identifiers are never translated** -- plugin ids, tool names, capability names. +# They are names, not words. + + +#: Plugin provenance. The distinction evolution actually cares about: a built-in +#: plugin shipped with the framework, a self-acquired one the framework installed +#: into the profile itself. Only the second kind is evidence of evolution. +_BUILT_IN = "built_in" +_SELF_ACQUIRED = "self_acquired" + + +def _percent(value: Any) -> str: + """Render a 0..1 model self-report as a percentage, or empty when absent. + + A confidence is a claim, never a permission, so it is shown for prioritisation + only -- and shown in a unit a reader cannot misread as a raw score. + """ + try: + ratio = float(value) + except (TypeError, ValueError): + return "" + return f"{max(0.0, min(1.0, ratio)) * 100:.0f}%" + +# Reachability statuses. ``no_evidence`` is deliberately distinct from +# ``unverifiable``: the first means nothing was observed (idle, or unwired), the +# second means the source could not be read at all. +WIRED = "wired" +NO_EVIDENCE = "no_evidence" +UNVERIFIABLE = "unverifiable" +NOT_ADMITTED = "not_admitted" + + +class EvolutionProducer: + """Emit one framework-evolution snapshot per cycle.""" + + domain = "framework_evolution" + + async def observe(self, ctx: ProducerContext) -> Sequence[Finding]: + """Return a single Finding describing the framework and its pipeline. + + Never raises: a transparency panel that fails takes the whole board page + with it, and the failure it would report is its own. + """ + try: + payload = self._build_payload(ctx) + except Exception: # noqa: BLE001 - observability must not break the cycle + # Logged at warning, not debug: every read inside is individually + # guarded and degrades to an ``unverifiable`` row, so reaching this + # handler means a defect in *this* producer rather than an unreadable + # source. Swallowing it keeps the cycle alive; hiding it at debug + # would leave the panel silently absent with no reason recorded. + logger.warning("evolution producer: snapshot failed", exc_info=True) + return () + + severity = self._severity(payload) + return ( + Finding( + watch_id=ctx.spec.watch_id or self.domain, + domain=self.domain, + title="Framework evolution", + summary=payload["summary"]["headline"], + severity=severity, + ts=payload["observed_at"], + tags=("framework_evolution", "self_evolution"), + evidence=self._evidence(payload), + suggested_actions=self._actions(payload), + # Content fingerprint, not a timestamp: the executor skips a + # finding whose dedup key already exists, so a key that changed + # every cycle would grow the table without adding information, + # and one built from the clock would defeat dedup entirely. An + # unchanged framework keeps the previous finding, whose content + # is identical and therefore still accurate. + dedup_key=f"evolution:{self._fingerprint(payload)}", + payload=payload, + ), + ) + + # ── payload assembly ────────────────────────────────────────────────── + + def __init__(self) -> None: + # Previous cycle's fiber states, for the transition diff below. Held on the + # producer because it is registered once and lives as long as the daemon; a + # fresh instance simply has no baseline and reports no transitions, which is + # the correct answer for the first cycle after a restart. + self._last_fibers: dict[str, str] = {} + + def _fiber_transitions( + self, fibers: Mapping[str, str], *, readable: bool + ) -> list[dict[str, Any]]: + """Fiber state changes since the previous cycle. + + Derived by diffing snapshots rather than by probing the state machine, and + that is the point: a fiber transition does not bump the registry version, so + the ACT probe cannot see it, and putting a probe on every transition method + would add an observability dependency to the lifecycle object for a fact the + presentation layer can reconstruct for free. + + What this recovers is the retry path -- ``LOADING -> FAILED -> LOADING`` -- + which is invisible everywhere else: the registry never changed, so no version + moved, and by the time a poll runs the fiber is usually back to ``active``. + Sampling means a transition completed entirely between two cycles is missed; + that is a stated limit of the diff, not a defect to work around, and the + alternative was instrumenting the state machine. + + An unreadable registry returns nothing **and leaves the baseline intact**. + Diffing against an empty snapshot would report every plugin as disposed, and + replacing the baseline with it would then report every plugin as newly + appeared on the next successful cycle -- two fabricated mass events from one + transient read failure. + """ + if not readable: + return [] + previous, self._last_fibers = self._last_fibers, dict(fibers) + if not previous: + return [] + rows: list[dict[str, Any]] = [] + for plugin_id, state in sorted(fibers.items()): + was = previous.get(plugin_id) + if was is None: + rows.append({"plugin_id": plugin_id, "from": "", "to": state, "kind": "appeared"}) + elif was != state: + rows.append({"plugin_id": plugin_id, "from": was, "to": state, "kind": "moved"}) + for plugin_id in sorted(set(previous) - set(fibers)): + rows.append( + {"plugin_id": plugin_id, "from": previous[plugin_id], "to": "", "kind": "gone"} + ) + return rows + + def _build_payload(self, ctx: ProducerContext) -> dict[str, Any]: + snapshot = self._live_registry_snapshot() + reachability = self._reachability(snapshot) + rebuilt = self._episodes(ctx) + # ``None`` means the history could not be rebuilt; ``()`` means there is + # genuinely none. Collapsing the two would report a local defect as an + # absence of data -- the same conflation the reachability rows exist to + # prevent, and it would be inconsistent for this panel to commit it. + episodes: tuple[Any, ...] = rebuilt or () + payload: dict[str, Any] = { + "observed_at": float(getattr(ctx, "now", 0.0) or 0.0), + "roster": snapshot["roster"], + "topology": snapshot["topology"], + # Two renderer-shaped projections of the same ownership relation. The + # graph in ``topology`` is the general form, but the shipped + # ``EntityGraph`` renderer is a badge cloud reading ``props.data`` as a + # flat list of items with a ``name`` -- binding it a nodes/edges + # mapping renders nothing at all and reports no fault. So the view + # binds these instead, and ``topology`` stays for a real graph + # renderer to consume later. + "capability_map": snapshot["capability_map"], + "capability_badges": snapshot["capability_badges"], + "conflicts": snapshot["conflicts"], + "reachability": reachability, + # Distribution charts rather than another table: "how much of the roster + # is trusted" and "how much of the pipeline is wired" are single-glance + # questions that a reader should not have to answer by counting rows. + # Both derive from fields already in the fingerprint, so neither can + # freeze independently of the table it summarises. + "trust_mix": self._trust_mix(snapshot["roster"]), + "reachability_mix": self._reachability_mix(reachability), + # Q1/Q5: growth versus inventory, and artifacts the framework acquired + # and then never used. Both derive from roster fields already in the + # fingerprint, so neither can freeze independently of the roster. + "provenance_mix": self._provenance_mix(snapshot["roster"]), + "reclaim_candidates": self._reclaim_candidates(snapshot["roster"]), + "fiber_transitions": self._fiber_transitions( + snapshot["fibers"], readable=bool(snapshot.get("registry_readable")) + ), + "episodes": [episode.to_dict() for episode in episodes], + # Same reason as the topology projections: the Timeline renderer reads + # ``props.data`` as a flat list of ``{title, summary, severity}``. + "timeline": self._timeline(episodes), + "mutation_matrix": self._mutation_matrix(episodes), + "degraded": not episodes, + } + traces = self._recent_traces() + payload["traces"] = traces + payload["trace_feed"] = self._trace_feed(traces) + payload["unadmitted"] = self._unadmitted(traces) + if rebuilt is None: + payload["degraded_kind"] = UNVERIFIABLE + payload["degraded_reason"] = ( + "The causal history could not be rebuilt: the decision or observation " + "records could not be read. This is a fault to investigate, not an " + "absence of activity. The live snapshot and pipeline reachability below " + "are unaffected." + ) + elif not episodes: + payload["degraded_kind"] = NO_EVIDENCE + payload["degraded_reason"] = ( + "No capability decision has been recorded yet, so there is no causal " + "history to rebuild. The live snapshot and pipeline reachability below " + "are unaffected." + ) + payload["summary"] = self._summary(snapshot, reachability, episodes, traces) + return payload + + # ── live traces (facts no store retains) ───────────────────────────── + + def _recent_traces(self) -> list[dict[str, Any]]: + """Flush the probe buffer, then read the newest traces back. + + Flushing here rather than on a separate schedule is what keeps the panel and + the file consistent: this producer is the only consumer, and it runs on the + monitor tick, which is the cold path the tap's contract requires. Probe sites + therefore only ever buffer. + + Empty is the normal state -- no sink is installed outside the daemon, and a + framework that has not mutated has nothing to report. Distinguished from a + read failure only in the log, because unlike the episode history there is no + "records exist but are unreadable" case to mistake it for: the store treats a + corrupt file as empty by design. + """ + try: + from leapflow.telemetry.evolution_tap import current_sink + + sink = current_sink() + if sink is not None and hasattr(sink, "flush"): + sink.flush() + except Exception: # noqa: BLE001 - a failed flush costs freshness, not the cycle + logger.debug("evolution producer: trace flush failed", exc_info=True) + store = self._json_store( + "evolution_traces_path", "evolution_trace_store", "JsonEvolutionTraceStore" + ) + if store is None: + return [] + try: + return [ + dict(row) + for row in store.list_traces(limit=_MAX_TRACES) + if isinstance(row, Mapping) + ] + except Exception: # noqa: BLE001 + logger.debug("evolution producer: traces unreadable", exc_info=True) + return [] + + @staticmethod + def _trace_feed(traces: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Flatten traces into the shape the Timeline renderer reads. + + Composition-phase registry traces are excluded: every daemon start replays + the initial plugin load, and letting that through would bury the rare real + mutation under a boot log. The registry marks the difference itself, so this + is a filter on a declared fact rather than a guess about the kind's name. + """ + rows: list[dict[str, Any]] = [] + for trace in traces: + detail = dict(trace.get("detail") or {}) + kind = str(trace.get("kind") or "") + if detail.get("phase") == "composition": + continue + severity = "info" + if kind == "trust_frozen": + severity = "alert" + elif kind in ("registry_plugin_unregistered", "registry_tools_unregistered"): + severity = "notable" + elif detail.get("not_admitted_reason"): + severity = "notable" + rows.append( + { + "title": f"{str(trace.get('stage') or '').upper()} · {kind}", + "summary": str(trace.get("summary") or ""), + "severity": severity, + } + ) + return rows + + @staticmethod + def _unadmitted(traces: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Teacher proposals that never entered the pipeline. + + The one thing on this board that exists in no store at all: an intent that + was proposed and not admitted writes no observation, so without this the + board would show an idle pipeline while the world model proposed on every + session -- indistinguishable from a model with nothing to say. + """ + rows: list[dict[str, Any]] = [] + for trace in traces: + detail = dict(trace.get("detail") or {}) + reason = str(detail.get("not_admitted_reason") or "") + if not reason: + continue + for intent in detail.get("intents") or []: + if not isinstance(intent, Mapping): + continue + rows.append( + { + "capability": str(intent.get("capability") or ""), + "hypothesis": str(intent.get("hypothesis") or ""), + # Formatted here, not in the view: a bare 0.8 in a column + # headed "Confidence" reads as a score out of some unstated + # maximum. + "confidence": _percent(intent.get("confidence")), + "reason": reason, + } + ) + return rows + + @staticmethod + def _provenance_mix(roster: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Self-acquired versus built-in, in that order. + + Self-acquired first because it is the number the board exists to report: a + framework that has grown has a non-zero bar here, and one that has not shows + a single built-in bar no matter how many plugins it ships with. + """ + counts: dict[str, int] = {} + for row in roster: + key = str(row.get("provenance") or _BUILT_IN) + counts[key] = counts.get(key, 0) + 1 + return [ + {"label": name, "value": counts[name]} + for name in (_SELF_ACQUIRED, _BUILT_IN) + if counts.get(name) + ] + + @staticmethod + def _reclaim_candidates(roster: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Self-acquired artifacts holding a tool name without earning it. + + The recorded LF-10 case: a generated plugin blocked by the risk ceiling keeps + its registration forever, occupying a tool name and padding the capability + list, because nothing reclaims an artifact no requirement can select. Listing + them is not the reclamation, but it is the first time the set has been + nameable. + """ + return [ + { + "plugin_id": row.get("plugin_id"), + "trust_level": row.get("trust_level"), + "selectable": row.get("selectable"), + "ever_used": row.get("ever_used"), + "tool_count": row.get("tool_count"), + } + for row in roster + if str(row.get("reclaimable")) == _YES + ] + + @staticmethod + def _trust_mix(roster: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Roster counted by trust class, in earned order. + + Fixed order rather than sorted by count, so the shape of the bars means the + same thing on every visit. Empty classes are dropped -- a zero bar carries no + information and only costs width. + """ + counts: dict[str, int] = {} + for row in roster: + counts[str(row.get("trust_class") or "unverified")] = ( + counts.get(str(row.get("trust_class") or "unverified"), 0) + 1 + ) + order = (_TRUST_CLASS_FROZEN, "new_unproven", "accruing", "trusted", "unverified") + return [{"label": name, "value": counts[name]} for name in order if counts.get(name)] + + @staticmethod + def _reachability_mix(reachability: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Pipeline segments counted by status, worst first. + + The one number this board exists to make visible: how much of the evolution + machinery shows runtime evidence versus how much only exists. + """ + counts: dict[str, int] = {} + for row in reachability: + counts[str(row.get("status"))] = counts.get(str(row.get("status")), 0) + 1 + order = (WIRED, NO_EVIDENCE, NOT_ADMITTED, UNVERIFIABLE) + return [{"label": name, "value": counts[name]} for name in order if counts.get(name)] + + def _episodes(self, ctx: ProducerContext) -> tuple[Any, ...] | None: + """Rebuild recent episodes from existing records. + + Returns ``None`` when the history could not be rebuilt at all, and ``()`` + when it was rebuilt and is genuinely empty. The caller depends on that + difference: "could not look" and "nothing to see" are different answers, + and only one of them is a fault. + + The ledger reads the plan and observation stores, so it is resolved per + cycle for the same reason the stores are: the profile layout is bound + during deferred daemon initialisation. + """ + plans = self._json_store( + "capability_plans_path", "capability_plan_store", "JsonCapabilityPlanStore" + ) + if plans is None: + return None + observations = self._json_store( + "capability_observations_path", + "capability_observation_store", + "JsonCapabilityObservationStore", + ) + trust, _usage = self._trust_and_usage() + try: + from leapflow.evolution import EvolutionLedger + + ledger = EvolutionLedger( + plan_store=plans, observation_store=observations, trust_ledger=trust + ) + return tuple( + ledger.recent_episodes( + limit=_MAX_EPISODES, now=float(getattr(ctx, "now", 0.0) or 0.0) + ) + ) + except Exception: # noqa: BLE001 - a missing timeline degrades the panel, not the cycle + logger.debug("evolution producer: ledger unavailable", exc_info=True) + return None + + @staticmethod + def _timeline(episodes: Sequence[Any]) -> list[dict[str, Any]]: + """Project episodes into the flat shape the Timeline renderer reads. + + Severity per row rather than one for the panel: a regression sitting among + ordinary episodes is the row a reader must not scroll past. + """ + rows: list[dict[str, Any]] = [] + for episode in episodes: + driver = episode.driver or "unknown" + action = episode.mutation_action or "none" + target = f" {episode.plugin_id}" if episode.plugin_id else "" + severity = "info" + if episode.gap_closure == REOPENED: + severity = "alert" + elif episode.gap_closure == STILL_OPEN or episode.status == ABORTED: + severity = "notable" + summary = episode.outcome or episode.status + if episode.capability: + summary = f"{episode.capability} · {summary}" + if episode.hypothesis: + summary = f"{summary} · {episode.hypothesis}" + rows.append( + { + "title": f"{driver} → {action}{target}", + "summary": summary, + "severity": severity, + } + ) + return rows + + @staticmethod + def _mutation_matrix(episodes: Sequence[Any]) -> list[dict[str, Any]]: + """One row per episode: what triggered it, what was decided, what came of it. + + The two proposal vocabularies stay in separate columns. They answer + different questions -- "should a human accept this" versus "where is this + capability in its journey" -- and merging them is the mistake the code + upstream explicitly warns against. + """ + return [ + { + "driver": episode.driver or "unknown", + "capability": episode.capability, + "policy_action": episode.policy_action, + "autonomy_level": episode.autonomy_level, + "mutation_action": episode.mutation_action, + "registry_delta": ( + f"{episode.registry_before} → {episode.registry_after}" + if episode.registry_after >= 0 + else "" + ), + "lifecycle_status": episode.lifecycle_status, + "gap_closure": episode.gap_closure, + "verification_tier": episode.verification_tier, + "trust_now": episode.trust_now, + # ``outcome`` is deliberately absent from the rendered columns: it is + # composed English prose, so it can never localise, and it restates + # what gap_closure + mutation_action + verification_tier already say + # in vocabularies the client can translate. It stays on the episode + # for the timeline, where narrative text is expected. + } + for episode in episodes + ] + + def _live_registry_snapshot(self) -> dict[str, Any]: + """Read the roster, topology and conflicts from the running registry. + + Every value here is a live read. When the registry cannot be reached the + snapshot reports ``registry_readable=False`` rather than an empty roster, + because "no plugins" and "could not look" are different answers and only + one of them is ever true of a running daemon. + """ + registry = None + try: + from leapflow.plugins import get_registry + + registry = get_registry() + except Exception: # noqa: BLE001 - degraded snapshot, not a failure + logger.debug("evolution producer: registry unavailable", exc_info=True) + + if registry is None: + return { + "registry_readable": False, + "registry_version": -1, + "roster": [], + # Empty rather than absent: an unreadable registry must not be read + # as "every fiber disappeared", which is what a missing key would + # look like to the transition diff. + "fibers": {}, + "topology": {"nodes": [], "edges": []}, + "capability_map": [], + "capability_badges": [], + "conflicts": [], + } + + trust, usage = self._trust_and_usage() + fibers = self._fiber_states() + owners: Mapping[str, str] = dict(getattr(registry, "tool_owners", {}) or {}) + handlers = set(dict(getattr(registry, "tool_handlers", {}) or {})) + plugins: Mapping[str, Any] = dict(getattr(registry, "plugins", {}) or {}) + + roster: list[dict[str, Any]] = [] + nodes: list[dict[str, Any]] = [] + edges: list[dict[str, Any]] = [] + capability_map: list[dict[str, Any]] = [] + seen_capabilities: set[str] = set() + + for plugin_id in sorted(plugins): + plugin = plugins[plugin_id] + tools = self._owned_tools(plugin_id, plugin, owners, handlers) + roster.append(self._roster_row(plugin_id, tools, fibers, trust, usage)) + if len(nodes) < _MAX_TOPOLOGY_NODES: + nodes.append({"id": f"plugin:{plugin_id}", "kind": "plugin", "label": plugin_id}) + for tool in tools: + self._add_tool_topology( + plugin_id, tool, nodes, edges, capability_map, seen_capabilities + ) + + return { + "registry_readable": True, + "registry_version": int(getattr(registry, "version", -1) or -1), + "roster": roster[:_MAX_ROSTER], + "fibers": fibers, + "topology": {"nodes": nodes, "edges": edges}, + "capability_map": capability_map[:_MAX_CAPABILITY_MAP], + # ``name`` is the key the badge-cloud renderer reads. + "capability_badges": [{"name": cap} for cap in sorted(seen_capabilities)], + "conflicts": self._conflicts(registry), + } + + @staticmethod + def _owned_tools( + plugin_id: str, + plugin: Any, + owners: Mapping[str, str], + handlers: set[str], + ) -> list[Any]: + """Return the tools this plugin actually owns and that are dispatchable. + + Tool names are one global namespace arbitrated first-wins, so a plugin's + declared tool list is not the same as the tools it owns. Filtering by + ``tool_owners`` and by the live handler table is what makes the topology + agree with what the model can actually call. + """ + result: list[Any] = [] + for tool in getattr(plugin, "tools", []) or []: + name = str(getattr(tool, "name", "") or "") + if not name: + continue + if owners and owners.get(name) != plugin_id: + continue + if handlers and name not in handlers: + continue + result.append(tool) + return result + + def _roster_row( + self, + plugin_id: str, + tools: list[Any], + fibers: Mapping[str, str], + trust: Any, + usage: Any, + ) -> dict[str, Any]: + level, trust_class, frozen = self._trust_of(plugin_id, trust) + ever_used = self._ever_used(plugin_id, usage) + selectable = not frozen + provenance = self._provenance(plugin_id) + return { + "plugin_id": plugin_id, + "fiber_state": fibers.get(plugin_id, "unknown"), + "trust_level": level, + "trust_class": trust_class, + # Whether the framework acquired this itself. The whole board is about + # evolution, and a built-in plugin is not evidence of any; separating the + # two is what lets a reader see growth rather than inventory. + "provenance": provenance, + # A frozen plugin still reports DRAFT, and TrustScorer only *scores* + # trust, so a frozen-but-registered plugin stays selectable unless + # FrozenExclusionScorer is injected. Surfacing both columns is what + # makes that window visible instead of implying it cannot happen. + "selectable": _NO if frozen else _YES, + # Deliberately a durable fact, not a live counter. Rates and call counts + # change every cycle, and this finding dedups on a content fingerprint -- + # a live metric would either churn a new row on every tick or (if left + # out of the fingerprint) freeze on the board while looking current. + # "Has this ever been selected" is the structural question evolution + # actually asks. Per-plugin error rates belong to ``plugin_health``. + "ever_used": _YES if ever_used else _NO, + "tool_count": len(tools), + # The reclamation case, decided here rather than in the view so one + # definition serves the roster column, the candidate list and the count: + # self-acquired, registered, and either unselectable or never once chosen. + "reclaimable": _YES + if (provenance == _SELF_ACQUIRED and not (selectable and ever_used)) + else _NO, + } + + @staticmethod + def _provenance(plugin_id: str) -> str: + """Whether this plugin was shipped or acquired by the framework itself. + + Read from the profile's version store, which records a source snapshot for + every plugin installed at runtime. That is a durable fact rather than an + inference from the module path, and it survives a reload -- a plugin's + in-memory identity says nothing about where it came from. + + Unreadable store means ``built_in``: claiming a plugin was self-acquired on + no evidence would overstate how much the framework has evolved, which is the + one direction this board must never exaggerate. + """ + try: + from leapflow.config import get_settings + from leapflow.storage.plugin_version_store import PluginVersionStore + + layout = getattr(get_settings(), "profile_layout", None) + versions_dir = getattr(layout, "plugin_versions_dir", None) + if versions_dir is None: + return _BUILT_IN + active = PluginVersionStore(versions_dir).active(plugin_id) + return _SELF_ACQUIRED if active else _BUILT_IN + except Exception: # noqa: BLE001 - provenance is a column, not a fault + return _BUILT_IN + + @staticmethod + def _trust_of(plugin_id: str, trust: Any) -> tuple[str, str, bool]: + if trust is None: + return "unverified", "unverified", False + try: + level = trust.level(plugin_id).name + except Exception: # noqa: BLE001 - one unreadable plugin must not blank the roster + return "unverified", "unverified", False + frozen = False + is_frozen = getattr(trust, "is_frozen", None) + if callable(is_frozen): + try: + frozen = bool(is_frozen(plugin_id)) + except Exception: # noqa: BLE001 + frozen = False + return level, (_TRUST_CLASS_FROZEN if frozen else _TRUST_CLASS.get(level, "unverified")), frozen + + @staticmethod + def _ever_used(plugin_id: str, usage: Any) -> bool: + """Whether this plugin has ever been selected. Unknown counts as not used.""" + if usage is None: + return False + try: + stats = usage.stats_for_plugin(plugin_id) + except Exception: # noqa: BLE001 - one unreadable plugin must not blank the roster + return False + return bool(stats is not None and int(getattr(stats, "total_calls", 0) or 0) > 0) + + @staticmethod + def _add_tool_topology( + plugin_id: str, + tool: Any, + nodes: list[dict[str, Any]], + edges: list[dict[str, Any]], + capability_map: list[dict[str, Any]], + seen_capabilities: set[str], + ) -> None: + """Add ``plugin -owns-> tool -provides-> capability`` to the graph and table.""" + name = str(getattr(tool, "name", "") or "") + if not name: + return + if len(nodes) < _MAX_TOPOLOGY_NODES: + nodes.append({"id": f"tool:{name}", "kind": "tool", "label": name}) + if len(edges) < _MAX_TOPOLOGY_EDGES: + edges.append({"source": f"plugin:{plugin_id}", "target": f"tool:{name}", "kind": "owns"}) + for capability in getattr(tool, "provides_capabilities", ()) or (): + cap = str(capability or "") + if not cap: + continue + if cap not in seen_capabilities and len(nodes) < _MAX_TOPOLOGY_NODES: + nodes.append({"id": f"capability:{cap}", "kind": "capability", "label": cap}) + seen_capabilities.add(cap) + if len(edges) < _MAX_TOPOLOGY_EDGES: + edges.append( + {"source": f"tool:{name}", "target": f"capability:{cap}", "kind": "provides"} + ) + if len(capability_map) < _MAX_CAPABILITY_MAP: + capability_map.append( + {"capability": cap, "tool": name, "plugin": plugin_id} + ) + + @staticmethod + def _conflicts(registry: Any) -> list[dict[str, Any]]: + try: + conflicts = list(getattr(registry, "conflicts", []) or []) + except Exception: # noqa: BLE001 + return [] + rows: list[dict[str, Any]] = [] + for conflict in conflicts[:_MAX_CONFLICTS]: + rows.append( + { + "tool_name": str(getattr(conflict, "tool_name", "")), + "kept_plugin": str(getattr(conflict, "kept_plugin", "")), + "rejected_plugin": str(getattr(conflict, "rejected_plugin", "")), + } + ) + return rows + + @staticmethod + def _fiber_states() -> dict[str, str]: + try: + from leapflow.plugins import get_scoped_registry + + fibers = getattr(get_scoped_registry(), "fibers", {}) or {} + return { + str(plugin_id): str(getattr(getattr(fiber, "state", ""), "value", "") or "unknown") + for plugin_id, fiber in dict(fibers).items() + } + except Exception: # noqa: BLE001 - roster degrades to fiber_state=unknown + logger.debug("evolution producer: fiber states unavailable", exc_info=True) + return {} + + @staticmethod + def _trust_and_usage() -> tuple[Any, Any]: + """Return the live trust ledger and usage tracker, or ``(None, None)``. + + Both come from the process-global advisor, which is absent in-process and + in tests; the roster then reports ``unverified`` rather than inventing a + level. + """ + try: + from leapflow.learning.plugin_advisor import get_default_advisor + + advisor = get_default_advisor() + except Exception: # noqa: BLE001 + return None, None + if advisor is None: + return None, None + return getattr(advisor, "_trust_ledger", None), getattr(advisor, "_usage_tracker", None) + + # ── pipeline reachability ───────────────────────────────────────────── + + def _reachability(self, snapshot: Mapping[str, Any]) -> list[dict[str, Any]]: + """Report the runtime evidence for each pipeline segment. + + Ordered as the pipeline runs. Each row carries the measurement that was + actually taken, so a reader can tell "nothing has happened yet" from + "this segment is not wired" from "the source could not be read" -- a + distinction the presence of a module can never make. + """ + settings = self._settings() + observations = self._json_store( + "capability_observations_path", + "capability_observation_store", + "JsonCapabilityObservationStore", + ) + rows = [ + self._segment_world_model_driver(observations), + self._segment_evidence_gate(settings), + self._segment_authorising_origins(settings), + self._segment_observations(observations), + self._segment_lifecycle(), + self._segment_plan_records(), + self._segment_trust(snapshot), + ] + rows.extend(self._segments_awaiting_wiring()) + return rows + + @staticmethod + def _settings() -> Any: + try: + from leapflow.config import get_settings + + return get_settings() + except Exception: # noqa: BLE001 + logger.debug("evolution producer: settings unavailable", exc_info=True) + return None + + def _segment_world_model_driver(self, store: Any) -> dict[str, Any]: + """Whether the world-model teacher's capability hypotheses reach the pipeline. + + The driver reports its own counts (proposed vs admitted) to the session-end + pipeline observer, which only logs and keeps them in memory -- so that + channel is not readable here. The one durable trace is an *admitted* + intent, which lands as an observation of kind ``world_model_intent``. + Absence of that trace cannot distinguish "the driver never ran" from "it + ran and the evidence gate correctly refused the intent", so this reports + ``unverifiable`` rather than guessing at either. + """ + if store is None: + return self._row( + "world_model_driver", + "World-model driver", + UNVERIFIABLE, + "observation store unreadable", + ) + try: + records = store.unresolved(min_count=1, limit=_MAX_ROSTER) + except Exception: # noqa: BLE001 + logger.debug("evolution producer: driver evidence read failed", exc_info=True) + return self._row( + "world_model_driver", + "World-model driver", + UNVERIFIABLE, + "observation store unreadable", + ) + admitted = [ + record + for record in records + if str((dict(record.get("result") or {})).get("error_type") or "") + == "world_model_intent" + ] + if admitted: + return self._row( + "world_model_driver", + "World-model driver", + WIRED, + f"{len(admitted)} admitted world-model intent(s)", + ) + return self._row( + "world_model_driver", + "World-model driver", + UNVERIFIABLE, + "no admitted world-model intent; the driver's own counts are not persisted", + next_step=( + "Proposed-but-not-admitted is the correct default and leaves no durable " + "trace. Admit the kind to make it observable: leap config set " + "accepted_evidence_kinds \"unknown_tool,world_model_intent\"" + ), + ) + + def _segment_evidence_gate(self, settings: Any) -> dict[str, Any]: + """Which evidence kinds may enter the observation pipeline.""" + if settings is None: + return self._row("evidence_gate", "Evidence admission", UNVERIFIABLE, "settings unreadable") + kinds = tuple(getattr(settings, "accepted_evidence_kinds", ()) or ()) + admitted = ", ".join(kinds) if kinds else "unknown_tool (default)" + if "world_model_intent" in kinds: + return self._row( + "evidence_gate", "Evidence admission", WIRED, f"accepted: {admitted}" + ) + return self._row( + "evidence_gate", + "Evidence admission", + NOT_ADMITTED, + f"accepted: {admitted}", + next_step=( + "World-model intents are proposed but not admitted. Run: leap config set " + "accepted_evidence_kinds \"unknown_tool,world_model_intent\"" + ), + ) + + def _segment_authorising_origins(self, settings: Any) -> dict[str, Any]: + """Which requirement origins may authorise acquiring new code.""" + if settings is None: + return self._row( + "authorising_origins", "Acquisition authority", UNVERIFIABLE, "settings unreadable" + ) + origins = tuple(getattr(settings, "evolution_authorising_origins", ()) or ()) + if origins: + return self._row( + "authorising_origins", + "Acquisition authority", + WIRED, + f"restricted to: {', '.join(origins)}", + ) + return self._row( + "authorising_origins", + "Acquisition authority", + NO_EVIDENCE, + "unrestricted (any origin may authorise)", + next_step=( + "Optional hardening: leap config set evolution_authorising_origins \"world_model\"" + ), + ) + + def _segment_observations(self, store: Any) -> dict[str, Any]: + """Whether capability evidence is accumulating.""" + if store is None: + return self._row("observations", "Capability observations", UNVERIFIABLE, "store unreadable") + try: + open_records = store.unresolved(min_count=1, limit=_MAX_ROSTER) + except Exception: # noqa: BLE001 + logger.debug("evolution producer: observation read failed", exc_info=True) + return self._row("observations", "Capability observations", UNVERIFIABLE, "store unreadable") + if open_records: + return self._row( + "observations", "Capability observations", WIRED, f"{len(open_records)} open" + ) + return self._row( + "observations", + "Capability observations", + NO_EVIDENCE, + "no open observations", + next_step="Nothing to act on: no capability gap has been recorded yet.", + ) + + def _segment_lifecycle(self) -> dict[str, Any]: + """Whether the trust/probation/quarantine tier has anything to govern.""" + store = self._json_store("capability_proposal_queue_path", "capability_proposal_queue", "JsonCapabilityProposalQueue") + if store is None: + return self._row("lifecycle", "Lifecycle records", UNVERIFIABLE, "queue unreadable") + try: + items = store.list_items(limit=0) + except Exception: # noqa: BLE001 + logger.debug("evolution producer: lifecycle read failed", exc_info=True) + return self._row("lifecycle", "Lifecycle records", UNVERIFIABLE, "queue unreadable") + if items: + counts: dict[str, int] = {} + for item in items: + status = str(getattr(item, "status", "") or "unknown") + counts[status] = counts.get(status, 0) + 1 + spread = ", ".join(f"{k}={v}" for k, v in sorted(counts.items())) + return self._row("lifecycle", "Lifecycle records", WIRED, spread) + return self._row( + "lifecycle", + "Lifecycle records", + NO_EVIDENCE, + "queue is empty", + next_step=( + "The governor has nothing to govern. A lifecycle record opens when " + "plugin_propose runs." + ), + ) + + def _segment_plan_records(self) -> dict[str, Any]: + """Whether the adaptive policy is actually deciding.""" + store = self._json_store("capability_plans_path", "capability_plan_store", "JsonCapabilityPlanStore") + if store is None: + return self._row("policy", "Policy decisions", UNVERIFIABLE, "plan store unreadable") + try: + latest = store.latest() or {} + except Exception: # noqa: BLE001 + logger.debug("evolution producer: plan read failed", exc_info=True) + return self._row("policy", "Policy decisions", UNVERIFIABLE, "plan store unreadable") + decision = dict(latest.get("policy_decision") or {}) if isinstance(latest, Mapping) else {} + action = str(decision.get("action") or "") + if action: + return self._row("policy", "Policy decisions", WIRED, f"latest action: {action}") + if latest: + return self._row( + "policy", "Policy decisions", NO_EVIDENCE, "plan recorded without a policy decision" + ) + return self._row( + "policy", + "Policy decisions", + NO_EVIDENCE, + "no capability plan recorded", + next_step="No adaptive decision has run yet.", + ) + + def _segment_trust(self, snapshot: Mapping[str, Any]) -> dict[str, Any]: + """Whether trust promotion/demotion is live. + + Promotion and demotion do run on live traffic; quarantine is a separate + segment because it needs its own feed and has historically had none. + """ + if not snapshot.get("registry_readable"): + return self._row("trust", "Trust accrual", UNVERIFIABLE, "registry unreadable") + roster = list(snapshot.get("roster") or []) + if not roster: + return self._row("trust", "Trust accrual", NO_EVIDENCE, "no plugins registered") + graded = [row for row in roster if str(row.get("trust_level")) != "unverified"] + if not graded: + return self._row( + "trust", + "Trust accrual", + UNVERIFIABLE, + "trust ledger not bound in this process", + next_step="Trust is reported by the daemon; an in-process run binds no advisor.", + ) + beyond_draft = [row for row in graded if str(row.get("trust_level")) != "DRAFT"] + frozen = [row for row in graded if str(row.get("trust_class")) == _TRUST_CLASS_FROZEN] + detail = f"{len(beyond_draft)}/{len(graded)} above DRAFT" + if frozen: + detail += f", {len(frozen)} frozen" + status = WIRED if beyond_draft or frozen else NO_EVIDENCE + return self._row("trust", "Trust accrual", status, detail) + + def _segments_awaiting_wiring(self) -> list[dict[str, Any]]: + """Report the segments whose capability exists but produces no evidence. + + Each of these has a module in the tree. That is deliberately *not* treated + as evidence: the module having no caller is exactly the failure mode this + panel exists to expose, so the row reports the absence of observed output + and names what would close it. + """ + awaiting = ( + ( + "effect_verification", + "Effect verification (L3)", + "no EffectVerdict observed", + "Closures currently rest on declared fitness (L2). Wire " + "CapabilityEffectVerifier to verify by observed effect.", + ), + ( + "quarantine_feed", + "Quarantine feed", + "no quarantine candidate observed", + "Trust demotion is live but quarantine has no feed. Wire " + "QuarantineCandidateTracker and drain on a cold path.", + ), + ( + "reclamation", + "Unselectable reclamation", + "no reclamation candidate observed", + "Wire UnselectableArtifactReaper to find artifacts no requirement can select.", + ), + ) + return [ + self._row(key, label, NO_EVIDENCE, evidence, next_step=next_step) + for key, label, evidence, next_step in awaiting + ] + + @staticmethod + def _row( + key: str, + stage: str, + status: str, + evidence: str, + *, + next_step: str = "", + ) -> dict[str, Any]: + return { + "key": key, + "stage": stage, + "status": status, + "evidence": evidence, + "next_step": next_step, + } + + @staticmethod + def _json_store(layout_attr: str, module: str, class_name: str) -> Any: + """Build a profile-scoped JSON store, or None when the layout is absent. + + Resolved lazily per cycle rather than cached: the profile layout is bound + during deferred daemon initialisation, so a store captured at construction + would either be missing or belong to a stale profile. + """ + try: + import importlib + + from leapflow.config import get_settings + + layout = getattr(get_settings(), "profile_layout", None) + path = getattr(layout, layout_attr, None) if layout is not None else None + if path is None: + return None + store_module = importlib.import_module(f"leapflow.storage.{module}") + return getattr(store_module, class_name)(path) + except Exception: # noqa: BLE001 - a missing store is a degraded row, not a fault + logger.debug("evolution producer: %s unavailable", class_name, exc_info=True) + return None + + # ── summary / severity / evidence ───────────────────────────────────── + + def _summary( + self, + snapshot: Mapping[str, Any], + reachability: Sequence[Mapping[str, Any]], + episodes: Sequence[Any], + traces: Sequence[Mapping[str, Any]] = (), + ) -> dict[str, Any]: + roster = list(snapshot.get("roster") or []) + conflicts = list(snapshot.get("conflicts") or []) + frozen = [row for row in roster if str(row.get("trust_class")) == _TRUST_CLASS_FROZEN] + unselectable = [row for row in roster if row.get("selectable") == _NO] + self_acquired = [row for row in roster if str(row.get("provenance")) == _SELF_ACQUIRED] + reclaimable = [row for row in roster if str(row.get("reclaimable")) == _YES] + blocked = [row for row in reachability if row["status"] in (NO_EVIDENCE, NOT_ADMITTED)] + unverifiable = [row for row in reachability if row["status"] == UNVERIFIABLE] + regressions = [ep for ep in episodes if ep.gap_closure == REOPENED] + mutations = [ep for ep in episodes if ep.framework_changed] + resolved = [ep for ep in episodes if ep.gap_closure == RESOLVED] + runtime_traces = [ + t for t in traces if dict(t.get("detail") or {}).get("phase") != "composition" + ] + frozen_traces = [t for t in traces if str(t.get("kind")) == "trust_frozen"] + unadmitted = sum( + len(dict(t.get("detail") or {}).get("intents") or []) + for t in traces + if dict(t.get("detail") or {}).get("not_admitted_reason") + ) + + if not snapshot.get("registry_readable"): + headline = "Runtime state could not be verified: the plugin registry is unreachable." + elif regressions: + headline = ( + f"{len(regressions)} evolution(s) regressed: a gap that was closed has recurred." + ) + elif frozen: + headline = ( + f"{len(roster)} plugins registered; {len(frozen)} frozen by an internal defect." + ) + elif conflicts: + headline = f"{len(roster)} plugins registered; {len(conflicts)} tool-name conflicts." + else: + headline = ( + f"{len(roster)} plugins registered; {len(episodes)} recent episode(s); " + f"{len(reachability) - len(blocked) - len(unverifiable)}" + f"/{len(reachability)} pipeline segments show runtime evidence." + ) + + return { + "headline": headline, + "registry_readable": bool(snapshot.get("registry_readable")), + "registry_version": snapshot.get("registry_version", -1), + "active_plugins": len(roster), + "tool_count": sum(int(row.get("tool_count") or 0) for row in roster), + "conflict_count": len(conflicts), + "frozen_count": len(frozen), + "unselectable_count": len(unselectable), + # Q1: the headline number for "is it growing". Distinct from the plugin + # count, which a framework has on day one without evolving at all. + "self_acquired_count": len(self_acquired), + # Q5: acquired and not earning its registration. + "reclaimable_count": len(reclaimable), + "episode_count": len(episodes), + "mutation_count": len(mutations), + "regression_count": len(regressions), + "resolved_count": len(resolved), + # Traces are the live half: facts no store retains. Counted separately + # from episodes because they answer "what just happened" rather than + # "why", and a reader must not read one as the other. + "trace_count": len(runtime_traces), + "observed": bool(traces), + "frozen_trace_count": len(frozen_traces), + # Proposed by the world model and admitted by nothing. Zero is both the + # healthy state and the switched-off state; the panel says which. + "unadmitted_intent_count": unadmitted, + "segments_total": len(reachability), + "segments_with_evidence": len(reachability) - len(blocked) - len(unverifiable), + # Every closure the engine records today rests on declared fitness, so + # the view must say so rather than let a reader infer that a retired + # observation proves the capability works. Shown whenever there is a + # closure to qualify. + "l2_only_closures": bool(resolved), + # Gates the second stat row. A healthy framework shows four numbers, not + # eight; this turns on only when one of them is non-zero, so the row's + # presence is itself the signal. + "attention": bool( + regressions + or frozen + or conflicts + or unadmitted + or frozen_traces + or reclaimable + ), + "suggestions": self._suggestions(frozen, conflicts, blocked, regressions), + } + + @staticmethod + def _suggestions( + frozen: Sequence[Mapping[str, Any]], + conflicts: Sequence[Mapping[str, Any]], + blocked: Sequence[Mapping[str, Any]], + regressions: Sequence[Any] = (), + ) -> list[dict[str, str]]: + chips: list[dict[str, str]] = [] + for episode in list(regressions)[:3]: + chips.append( + { + "label": f"Regression on {episode.capability or episode.plugin_id or 'a capability'}", + "detail": ( + "A retired observation recurred: the evolution looked successful " + "and the gap came back." + ), + } + ) + for row in frozen[:3]: + chips.append( + { + "label": f"Inspect frozen plugin {row.get('plugin_id')}", + "detail": "Frozen by an internal defect; it still reports DRAFT.", + } + ) + for row in conflicts[:3]: + chips.append( + { + "label": f"Resolve conflict on {row.get('tool_name')}", + "detail": f"{row.get('rejected_plugin')} lost the name to {row.get('kept_plugin')}.", + } + ) + for row in blocked[:4]: + if row.get("next_step"): + chips.append({"label": row["stage"], "detail": row["next_step"]}) + return chips + + @staticmethod + def _severity(payload: Mapping[str, Any]) -> Severity: + """Escalate only on facts a person must act on. + + A segment with no evidence is not an alert: an idle pipeline and an + unadmitted evidence kind are both correct, quiet states. A regression is + the opposite -- a gap that was closed has come back, so an evolution that + looked successful was not -- and it is the one finding here worth waking + someone for. Conflicts and frozen-yet-selectable plugins sit between: + each means the live capability set is not what it appears to be. + """ + summary = dict(payload.get("summary") or {}) + if summary.get("regression_count"): + return Severity.ALERT + if not summary.get("registry_readable"): + return Severity.NOTABLE + if summary.get("frozen_count") or summary.get("conflict_count"): + return Severity.NOTABLE + return Severity.INFO + + @staticmethod + def _evidence(payload: Mapping[str, Any]) -> tuple[Evidence, ...]: + summary = dict(payload.get("summary") or {}) + rows = [ + Evidence(kind="metric", label="registry_version", value=str(summary.get("registry_version"))), + Evidence(kind="metric", label="active_plugins", value=str(summary.get("active_plugins"))), + Evidence(kind="metric", label="tools", value=str(summary.get("tool_count"))), + Evidence(kind="metric", label="episodes", value=str(summary.get("episode_count"))), + Evidence( + kind="metric", + label="pipeline_evidence", + value=f"{summary.get('segments_with_evidence')}/{summary.get('segments_total')}", + ), + ] + if summary.get("regression_count"): + rows.append( + Evidence( + kind="metric", label="regressions", value=str(summary.get("regression_count")) + ) + ) + if summary.get("conflict_count"): + rows.append( + Evidence(kind="metric", label="conflicts", value=str(summary.get("conflict_count"))) + ) + if summary.get("frozen_count"): + rows.append( + Evidence(kind="metric", label="frozen", value=str(summary.get("frozen_count"))) + ) + return tuple(rows) + + @staticmethod + def _actions(payload: Mapping[str, Any]) -> tuple[SuggestedAction, ...]: + return ( + SuggestedAction( + name="plugin_list", + label="Inspect live plugin registry", + kind="intent", + params={}, + ), + ) + + @staticmethod + def _fingerprint(payload: Mapping[str, Any]) -> str: + """Identify the framework *state*, so an unchanged framework re-notifies once. + + Built from what a reader would react to -- the registry version, each + plugin's fiber state, trust and whether it has ever been selected, + conflicts, and each pipeline segment's status -- and deliberately not from + ``observed_at``, which changes every cycle and would defeat dedup + entirely. Every field the board renders is covered here: a rendered value + left out of the fingerprint freezes on the page while still looking + current, which is worse than not showing it. + """ + summary = dict(payload.get("summary") or {}) + roster = [ + f"{row.get('plugin_id')}:{row.get('fiber_state')}:{row.get('trust_level')}" + f":{row.get('selectable')}:{row.get('ever_used')}:{row.get('provenance')}" + for row in payload.get("roster") or [] + ] + conflicts = [ + f"{row.get('tool_name')}>{row.get('rejected_plugin')}" + for row in payload.get("conflicts") or [] + ] + segments = [f"{row.get('key')}={row.get('status')}" for row in payload.get("reachability") or []] + # Episodes participate by identity and outcome. Without them a new episode + # would leave the key unchanged and the executor would skip the write, + # freezing the timeline on the page while the ledger moved on. + episodes = [ + f"{row.get('episode_id')}:{row.get('status')}:{row.get('gap_closure')}" + f":{row.get('mutation_action')}:{row.get('trust_now')}" + for row in payload.get("episodes") or [] + ] + # Traces participate by identity, for the same reason: a new trace with an + # unchanged registry (a trust transition, an unadmitted proposal) must still + # refresh the page. + traces = [ + f"{row.get('trace_id')}" for row in payload.get("traces") or [] + ] + # Fiber transitions are a per-cycle delta, so they enter the key too. One + # consequence is deliberate: after a retry that ends where it started, the + # next quiet cycle reproduces the pre-transition key and its write is + # skipped, so the board keeps showing the retry rather than erasing the only + # evidence it ever happened. + transitions = [ + f"{row.get('plugin_id')}:{row.get('from')}>{row.get('to')}" + for row in payload.get("fiber_transitions") or [] + ] + material = "|".join( + [ + str(summary.get("registry_version")), + str(int(bool(summary.get("registry_readable")))), + ",".join(sorted(roster)), + ",".join(sorted(conflicts)), + ",".join(segments), + ",".join(episodes), + ",".join(sorted(traces)), + ",".join(transitions), + ] + ) + import hashlib + + return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16] + + +__all__ = ["EvolutionProducer", "NOT_ADMITTED", "NO_EVIDENCE", "UNVERIFIABLE", "WIRED"] diff --git a/src/leapflow/plugins/adaptive_loop.py b/src/leapflow/plugins/adaptive_loop.py index 09827fe2..59dcceda 100644 --- a/src/leapflow/plugins/adaptive_loop.py +++ b/src/leapflow/plugins/adaptive_loop.py @@ -372,10 +372,20 @@ def unmet_requirements( *, candidate_filter: CandidateFilter | None = None, scorers: Any = None, + authorising_origins: Sequence[str] | None = None, ) -> tuple[CapabilityRequirement, ...]: """Resolution-first gap gate: return only requirements the live registry cannot already satisfy. + ``authorising_origins`` restricts which requirement *origins* may drive an + acquisition. Empty or ``None`` means unrestricted, which is the shipped + behaviour. Setting it to ``("world_model",)`` is the enforceable form of + "self-evolution is first-driven by the world model": a requirement from any + other origin is still resolved and still reported by the caller, but is + excluded from the gap set, so it cannot reach the proposal queue. The filter + is applied *before* resolution so an unauthorised origin cannot even consume + resolver work. + Shipped adaptive evolution has no short-circuit between "a requirement exists" and "propose a plugin", so it can generate and install a capability the live catalog already provides. This method resolves each @@ -394,6 +404,15 @@ def unmet_requirements( the resolver's default scorers. """ self._registry.assemble() + # Authority filter first: an origin that may not drive acquisition should not + # consume resolver work, and must not appear in the gap set at all. + authorised = tuple(requirements) + if authorising_origins: + from leapflow.learning.outcome_governance_feed import filter_authorised + + authorised = filter_authorised(authorised, authorising_origins) + if not authorised: + return () candidates = tuple(candidates_from_registry(self._registry)) if candidate_filter is not None: candidates = tuple(c for c in candidates if candidate_filter(c)) @@ -403,7 +422,7 @@ def unmet_requirements( trust_ledger=self._trust_ledger, usage_tracker=self._usage_tracker, ) - resolutions = resolver.resolve_all(tuple(requirements), candidates, context) + resolutions = resolver.resolve_all(authorised, candidates, context) return tuple(r.requirement for r in resolutions if r.unmet) async def run(self, request: AdaptiveLoopRequest) -> AdaptiveLoopResult: diff --git a/src/leapflow/plugins/adaptive_policy.py b/src/leapflow/plugins/adaptive_policy.py index 3b663345..731ff9b9 100644 --- a/src/leapflow/plugins/adaptive_policy.py +++ b/src/leapflow/plugins/adaptive_policy.py @@ -10,7 +10,7 @@ from typing import Any, Literal, Mapping from leapflow.learning.plugin_trust import PluginTrustLevel -from leapflow.storage.capability_proposal_queue import CapabilityProposalItem +from leapflow.plugins.evolution_contracts import EvolutionProposalView AutonomyLevel = Literal[ "observe_only", @@ -88,7 +88,7 @@ def autonomy_level(self) -> AutonomyLevel: def decide( self, - proposal: CapabilityProposalItem, + proposal: EvolutionProposalView, *, trust_level: PluginTrustLevel | str | int = PluginTrustLevel.DRAFT, usage: Mapping[str, Any] | None = None, @@ -197,7 +197,7 @@ def decide( ) -def _risk_level(proposal: CapabilityProposalItem) -> str: +def _risk_level(proposal: EvolutionProposalView) -> str: risk = dict(proposal.risk or {}) value = str(risk.get("risk_level") or risk.get("max_risk_level") or "read_only") for requirement in proposal.requirements: diff --git a/src/leapflow/plugins/capability_resolver.py b/src/leapflow/plugins/capability_resolver.py index f0567995..5036f034 100644 --- a/src/leapflow/plugins/capability_resolver.py +++ b/src/leapflow/plugins/capability_resolver.py @@ -378,6 +378,46 @@ def score( ) +class FrozenExclusionScorer: + """Exclude a candidate whose plugin trust has been permanently frozen. + + Defense in depth at the selection layer. ``TrustScorer`` only *scores* trust, + so a plugin frozen by an internal defect stays selectable for as long as it + remains registered -- "frozen implies never re-selected" holds today only + because ``LifecycleGovernor`` also quarantines (and thus unregisters) on the + same event. Any path that freezes trust without unregistering would leave the + plugin eligible; this scorer closes that independently of governance. + + Not in ``_DEFAULT_SCORERS``: it is injected explicitly + (``CapabilityResolver(scorers=...)``), so default resolution is unchanged. + """ + + name = "frozen_exclusion" + + def score( + self, + requirement: CapabilityRequirement, + candidate: CapabilityCandidate, + context: ResolverContext, + ) -> ScoreComponent: + ledger = context.trust_ledger + is_frozen = getattr(ledger, "is_frozen", None) if ledger is not None else None + if callable(is_frozen) and is_frozen(candidate.plugin_id): + return ScoreComponent( + self.name, + 0.0, + context.weights.trust, + f"plugin {candidate.plugin_id!r} is frozen by an internal defect", + excluded=True, + ) + return ScoreComponent( + self.name, + 1.0, + context.weights.trust, + "plugin is not frozen", + ) + + class ReliabilityScorer: name = "reliability" diff --git a/src/leapflow/plugins/evolution_contracts.py b/src/leapflow/plugins/evolution_contracts.py new file mode 100644 index 00000000..8bf4b34a --- /dev/null +++ b/src/leapflow/plugins/evolution_contracts.py @@ -0,0 +1,91 @@ +"""Contracts for the capability-evolution lifecycle. + +``AdaptiveEvolutionPolicy`` and ``LifecycleGovernor`` are the trust, probation and +quarantine machinery. They were written against one concrete backing store, which +left them reachable only from whatever fills that store -- in practice, nothing in +production. These Protocols state what each component actually requires, so either +can be driven by any store that satisfies the contract, including one fed by the +live ``plugin_propose -> plugin_generate -> plugin_install`` chain. + +Two distinct vocabularies meet here, and conflating them is the mistake to avoid: + +* ``domain.plugin_proposal.ProposalStatus`` -- ``draft | review | approved | + rejected`` -- is a **review** state: should a human accept this proposal? +* ``storage.capability_proposal_queue.ProposalStatus`` -- ``PENDING | GENERATED | + APPROVED | INSTALLED | PROBATION | VERIFIED | REJECTED | FAILED | QUARANTINED`` + -- is an **acquisition lifecycle** state: where is this capability in its + journey from hypothesis to trusted? + +They are not duplicates and must not be merged into one field. A proposal that a +human has ``approved`` may still be anywhere in its lifecycle. The lifecycle store +below owns the second vocabulary. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Protocol, Sequence, runtime_checkable + + +@runtime_checkable +class EvolutionProposalView(Protocol): + """What ``AdaptiveEvolutionPolicy`` reads when deciding the next action. + + A read-only projection: the policy inspects lifecycle status and declared risk + and returns a decision. It never writes, so any object exposing these + attributes can be evaluated -- including a view backed by a live plugin + proposal rather than the default queue item. + """ + + proposal_id: str + status: str + requirements: tuple[Mapping[str, Any], ...] + risk: Mapping[str, Any] + + +@runtime_checkable +class EvolutionLifecycleStore(Protocol): + """Where ``LifecycleGovernor`` records lifecycle transitions. + + The governor reads nothing back during a transition; it writes the new status + plus the evidence for it (trust state, outcome, install result). Implementations + must treat an unknown ``proposal_id`` as a no-op rather than raising, because a + governance write must never fail the turn that produced the outcome. + """ + + def get(self, proposal_id: str) -> Any | None: + """Return the stored record, or ``None`` when it is unknown.""" + ... + + def update( + self, + proposal_id: str, + *, + status: str | None = None, + policy_decision: Mapping[str, Any] | None = None, + install_result: Mapping[str, Any] | None = None, + test_results: Sequence[Mapping[str, Any]] | None = None, + trust_state: Mapping[str, Any] | None = None, + ) -> Any: + """Apply a lifecycle transition and return the updated record.""" + ... + + +@runtime_checkable +class OutcomeStore(Protocol): + """Where ``LifecycleGovernor`` records per-execution outcomes. + + ``failure_streak`` is the consecutive-failure count the governor compares + against its quarantine threshold, so an implementation must reset it on + success. + """ + + def add_outcome(self, **kwargs: Any) -> Mapping[str, Any]: + """Record one outcome and return the stored record.""" + ... + + def failure_streak(self, plugin_id: str) -> int: + """Consecutive failures for the plugin, reset by any success.""" + ... + + +__all__ = ["EvolutionLifecycleStore", "EvolutionProposalView", "OutcomeStore"] diff --git a/src/leapflow/plugins/lifecycle_governor.py b/src/leapflow/plugins/lifecycle_governor.py index e6f52853..aadfc188 100644 --- a/src/leapflow/plugins/lifecycle_governor.py +++ b/src/leapflow/plugins/lifecycle_governor.py @@ -6,6 +6,7 @@ from typing import Any, Mapping from leapflow.learning.plugin_trust import PluginTrustLedger, PluginTrustLevel +from leapflow.plugins.evolution_contracts import EvolutionLifecycleStore, OutcomeStore @dataclass(frozen=True) @@ -38,13 +39,18 @@ def to_dict(self) -> dict[str, Any]: class LifecycleGovernor: - """Update proposal lifecycle state from trust and execution outcomes.""" + """Update proposal lifecycle state from trust and execution outcomes. + + The stores are Protocol-typed rather than concrete so this machinery can be + driven by whichever backing the live acquisition chain uses, not only by the + default capability-proposal queue. + """ def __init__( self, *, - proposal_queue: Any, - outcome_store: Any, + proposal_queue: EvolutionLifecycleStore, + outcome_store: OutcomeStore, lifecycle_actor: Any = None, trust_ledger: PluginTrustLedger | None = None, quarantine_after: int = 3, diff --git a/src/leapflow/plugins/registry.py b/src/leapflow/plugins/registry.py index 20e5776a..60fea000 100644 --- a/src/leapflow/plugins/registry.py +++ b/src/leapflow/plugins/registry.py @@ -163,7 +163,7 @@ def register(self, plugin: ToolPlugin) -> None: if not isinstance(plugin, ToolPlugin): raise TypeError(f"Plugin must satisfy ToolPlugin Protocol: {type(plugin)}") self._plugins[plugin.plugin_id] = plugin - self._version += 1 + self._bump_version("plugin_registered", plugin_id=plugin.plugin_id, tools=len(plugin.tools)) logger.debug("Registered tool plugin: %s (%d tools)", plugin.plugin_id, len(plugin.tools)) # ── Built-in Discovery ── @@ -242,8 +242,12 @@ def assemble(self) -> None: for tool in plugin.tools: self._index_tool(tool, plugin.plugin_id) + # Bumped before the flag flips, so this trace is classified as boot + # composition rather than a runtime change. Assembly *is* the composition + # event; labelling it ``runtime`` made every daemon start publish an + # ``evolution.*`` event and put a boot row in the live activity feed. + self._bump_version("assembled", plugins=len(self._plugins), tools=len(self._tool_handlers)) self._assembled = True - self._version += 1 logger.info( "Tool registry assembled: %d plugins, %d tools", @@ -268,7 +272,9 @@ def publish_plugin_tools(self, plugin: ToolPlugin) -> list[str]: if self._assembled: for tool in plugin.tools: self._index_tool(tool, plugin.plugin_id) - self._version += 1 + self._bump_version( + "tools_published", plugin_id=plugin.plugin_id, tool_names=list(tool_names) + ) return tool_names def register_late_tool( @@ -288,7 +294,7 @@ def register_late_tool( self._tool_definitions.append(definition) self._tool_handlers[name] = handler self._tool_owner[name] = owner - self._version += 1 + self._bump_version("late_tool_registered", tool_name=name, owner=owner) def _index_tool(self, tool: ToolMetadata, owner: str) -> None: """Add one tool to the metadata, schema, and handler indexes. @@ -353,7 +359,7 @@ def unregister_plugin(self, plugin_id: str) -> bool: self._conflicts = [ c for c in self._conflicts if plugin_id not in (c.kept_plugin, c.rejected_plugin) ] - self._version += 1 + self._bump_version("plugin_unregistered", plugin_id=plugin_id, tools=sorted(owned)) return True def unregister_tools(self, tool_names: Iterable[str]) -> int: @@ -366,7 +372,7 @@ def unregister_tools(self, tool_names: Iterable[str]) -> int: names_set = set(tool_names) removed = self._remove_tools_by_name(names_set) if removed > 0: - self._version += 1 + self._bump_version("tools_unregistered", tool_names=sorted(names_set), removed=removed) return removed def _remove_tools_by_name(self, names: set[str]) -> int: @@ -447,7 +453,44 @@ def version(self) -> int: def notify_mutation(self) -> None: """Public API to signal a mutation happened (increments version).""" + self._bump_version("scope_disposed") + + def _bump_version(self, kind: str, **detail: Any) -> None: + """Single point where the registry's version changes, and is observed. + + Convergence here is not cosmetic. The version was previously incremented at + seven separate statements across five methods, so a probe placed at any one + of them -- including ``notify_mutation``, which only two scope-disposal + callers reach -- would silently miss the rest. Routing every increment + through one method makes "the capability set changed" a single fact, which + is the only way an observer of it can be trusted. + + ``_assembled`` separates boot-time composition from a later runtime change. + Both bump the version, but only the second is an *evolution*: without the + distinction, every daemon start would bury the rare real mutation under a + replay of the initial plugin load. + """ self._version += 1 + try: + from leapflow.domain.evolution_trace import EvolutionStage + from leapflow.telemetry.evolution_tap import emit_trace, is_enabled + + if not is_enabled(): + return + emit_trace( + EvolutionStage.ACT, + f"registry_{kind}", + correlation={"registry_version": str(self._version)}, + summary=f"registry {kind} -> v{self._version}", + detail={ + "version": self._version, + "phase": "runtime" if self._assembled else "composition", + "conflicts": [c.to_dict() for c in self._conflicts], + **detail, + }, + ) + except Exception: # noqa: BLE001 - the registry must never fail on telemetry + logger.debug("registry: evolution trace failed", exc_info=True) @property def last_bound_deps(self) -> dict[str, Any]: diff --git a/src/leapflow/plugins/tool_plugins/self_management.py b/src/leapflow/plugins/tool_plugins/self_management.py index 8b52e1b4..69d433f1 100644 --- a/src/leapflow/plugins/tool_plugins/self_management.py +++ b/src/leapflow/plugins/tool_plugins/self_management.py @@ -86,6 +86,12 @@ def __init__(self) -> None: # Optional persistent store for PluginProposal review queue. When not # injected, it is resolved lazily from ProfileLayout.plugin_proposals_path. self._plugin_proposal_store: Any = None + # Acquisition-lifecycle ledger (PENDING -> GENERATED -> INSTALLED -> + # PROBATION -> VERIFIED/QUARANTINED). Distinct from the review store above: + # that one answers "should a human accept this proposal", this one tracks + # where the capability is in its journey, and is what AdaptiveEvolutionPolicy + # and LifecycleGovernor operate on. + self._capability_lifecycle_store: Any = None # Optional version store; lazily resolved from ProfileLayout.plugin_versions_dir. self._plugin_version_store: Any = None # Optional adaptive capability decision store; lazily resolved from @@ -112,6 +118,7 @@ def dependencies(self) -> list[str]: "plugin_proposal_store", "plugin_version_store", "capability_plan_store", + "capability_lifecycle_store", ] def bind_runtime(self, **deps: Any) -> None: @@ -135,6 +142,8 @@ def bind_runtime(self, **deps: Any) -> None: self._plugin_version_store = deps["plugin_version_store"] if "capability_plan_store" in deps: self._capability_plan_store = deps["capability_plan_store"] + if "capability_lifecycle_store" in deps: + self._capability_lifecycle_store = deps["capability_lifecycle_store"] # ── Read-only introspection ──────────────────────────── @@ -532,10 +541,13 @@ async def _plugin_propose_handler( except (RuntimeError, OSError, ValueError, AttributeError) as exc: return {"ok": False, "error": f"Proposal persistence failed: {exc}"} + lifecycle_id = self._open_lifecycle_record(stored, requested_capability) + return { "ok": True, "action": "propose", "proposal": stored.to_dict(), + "lifecycle_proposal_id": lifecycle_id, "next_actions": [ "Review proposal fields and risk level.", "If acceptable, call plugin_generate with proposal_id to preserve review metadata.", @@ -857,6 +869,97 @@ def _proposal_store(self) -> Any: self._plugin_proposal_store = JsonPluginProposalStore(profile_layout.plugin_proposals_path) return self._plugin_proposal_store + def _lifecycle_store(self) -> Any: + """Resolve the profile-scoped acquisition-lifecycle ledger.""" + if self._capability_lifecycle_store is not None: + return self._capability_lifecycle_store + from leapflow.config import get_settings + from leapflow.storage.capability_proposal_queue import JsonCapabilityProposalQueue + + settings = get_settings() + profile_layout = getattr(settings, "profile_layout", None) + if profile_layout is None: + raise RuntimeError("profile_layout is required for capability lifecycle storage") + self._capability_lifecycle_store = JsonCapabilityProposalQueue( + profile_layout.capability_proposal_queue_path + ) + return self._capability_lifecycle_store + + def _open_lifecycle_record(self, proposal: Any, capability: str) -> str: + """Open a PENDING lifecycle record correlated with a review proposal. + + This is what makes the trust/probation/quarantine tier reachable: without a + lifecycle record there is nothing for ``AdaptiveEvolutionPolicy`` to decide + about or for ``LifecycleGovernor`` to transition. Returns the lifecycle + proposal id, or ``""`` when no ledger is available. + + Failures are contained: a bookkeeping write must never fail the proposal + the caller actually asked for. + """ + try: + from leapflow.domain.capability_requirement import CapabilityRequirement + + requirement = CapabilityRequirement.create( + capability or proposal.plugin_id, + "explicit_request", + evidence=proposal.capability_summary, + max_risk_level=proposal.risk_level, + requirement_id=f"req-review-{proposal.proposal_id}", + ) + item = self._lifecycle_store().enqueue( + requirements=[requirement], + risk={"risk_level": proposal.risk_level}, + source="plugin_propose", + metadata={ + "plugin_id": proposal.plugin_id, + "review_proposal_id": proposal.proposal_id, + }, + ) + self._trace_lifecycle_opened(item, proposal, requirement) + return str(item.proposal_id) + except (RuntimeError, OSError, ValueError, TypeError, AttributeError): + logger.debug("self_management: lifecycle record not opened", exc_info=True) + return "" + + @staticmethod + def _trace_lifecycle_opened(item: Any, proposal: Any, requirement: Any) -> None: + """Emit the one durable sign that the governance tier was actually driven. + + The queue records the item, but not what it was opened *for*: the link from a + review proposal and a requirement to a lifecycle record lives only here. That + link is what distinguishes "trust, probation and quarantine exist" from + "something reached them" -- a distinction that mattered, because this + machinery was for a long time unreachable in production and invisible while + it was. + """ + try: + from leapflow.domain.evolution_trace import EvolutionStage + from leapflow.telemetry.evolution_tap import emit_trace, is_enabled + + if not is_enabled(): + return + emit_trace( + EvolutionStage.DECIDE, + "lifecycle_opened", + correlation={ + "lifecycle_proposal_id": str(getattr(item, "proposal_id", "")), + "review_proposal_id": str(getattr(proposal, "proposal_id", "")), + "requirement_id": str(getattr(requirement, "requirement_id", "")), + "plugin_id": str(getattr(proposal, "plugin_id", "")), + }, + summary=( + f"lifecycle record opened for {getattr(proposal, 'plugin_id', '')}" + ), + detail={ + "source": "plugin_propose", + "risk_level": str(getattr(proposal, "risk_level", "")), + "status": str(getattr(item, "status", "")), + "capability": str(getattr(requirement, "capability", "")), + }, + ) + except Exception: # noqa: BLE001 - bookkeeping must not fail the proposal + logger.debug("self_management: evolution trace failed", exc_info=True) + def _version_store(self) -> Any: """Resolve the profile-scoped plugin version store.""" if self._plugin_version_store is not None: @@ -943,8 +1046,67 @@ async def _install_from_code( logger.debug( "plugin version recording skipped for %s: %s", plugin_id, exc, exc_info=True ) + version_info = {} + # An artifact this path installed is one self-evolution acquired, so later + # sweeps may verify its effect and reclaim it if nothing can ever select it. + # A hand-installed plugin is deliberately never recorded here. + self._record_acquisition(plugin_id) + self._trace_artifact_installed(plugin_id, proposal, version_info, result) return result + @staticmethod + def _record_acquisition(plugin_id: str) -> None: + """Note the acquisition for the cold-path co-evolution sweep; never raises.""" + try: + from leapflow.evolution.observations import record_acquisition + + record_acquisition(plugin_id) + except Exception: # noqa: BLE001 - bookkeeping must not fail an install + logger.debug("acquisition not recorded for %s", plugin_id, exc_info=True) + + @staticmethod + def _trace_artifact_installed( + plugin_id: str, proposal: Any, version_info: Any, result: Any + ) -> None: + """Record the artifact identity behind a completed acquisition. + + A capability transition has to be reconstructable end to end, and the piece no + store held was the link from the causal proposal to the *artifact* that ended + up registered. The version store knows the digest; the proposal knows why. This + joins them so an installed plugin can always be traced back to the requirement + that asked for it. + """ + try: + from leapflow.domain.evolution_trace import EvolutionStage + from leapflow.telemetry.evolution_tap import emit_trace, is_enabled + + if not is_enabled(): + return + info = dict(version_info or {}) + emit_trace( + EvolutionStage.ACT, + "artifact_installed", + correlation={ + "plugin_id": str(plugin_id), + "proposal_id": str(getattr(proposal, "proposal_id", "")), + }, + summary=f"installed {plugin_id} v{info.get('version', '')}", + detail={ + "plugin_id": str(plugin_id), + "version": str(info.get("version", "")), + "digest": str( + info.get("checksum_sha256") + or info.get("sha256") + or info.get("digest") + or "" + ), + "capability": str(getattr(proposal, "capability", "")), + "installed_tools": list(dict(result or {}).get("installed_tools", ()) or ()), + }, + ) + except Exception: # noqa: BLE001 - telemetry must never fail an install + logger.debug("artifact install trace failed", exc_info=True) + def _resolve_dsh_install_dir(self) -> "Path": """Resolve the profile-owned directory for DSH source bundles.""" from pathlib import Path diff --git a/src/leapflow/storage/capability_observation_store.py b/src/leapflow/storage/capability_observation_store.py index 013e2019..5f2bea9d 100644 --- a/src/leapflow/storage/capability_observation_store.py +++ b/src/leapflow/storage/capability_observation_store.py @@ -27,6 +27,19 @@ "failure_code", "capability", "tool_name", + # Declarations the detector needs to rebuild a requirement from a + # persisted observation. Dropping these silently changed behaviour rather + # than failing: without ``max_risk_level`` the requirement inherited the + # domain default of ``external`` -- the *most permissive* ceiling -- so a + # capability declared ``read_only`` came back from the store able to + # select mutating tools. Without ``origin`` a world-model intent was + # indistinguishable from an environment probe. + "origin", + "max_risk_level", + "requested_max_risk_level", + "intent_id", + "target_affordance", + "expected_effect", } ) @@ -86,6 +99,14 @@ def add_observation( record["occurrence_count"] = int(record.get("occurrence_count") or 0) + 1 record["result"] = safe_result record["environment"] = env + # A recurrence reopens a retired record. Without this, marking an + # observation resolved would silence that gap permanently: dedup would + # keep merging into the closed record and ``unresolved()`` -- which + # filters on ``status == "open"`` -- would never surface the + # regression again. + if str(record.get("status") or "open") != "open": + record["status"] = "open" + record["status_reason"] = f"reopened after recurrence at {now}" record["session_id"] = str(session_id or record.get("session_id") or "") record["turn_id"] = str(turn_id or record.get("turn_id") or "") record["workspace_root"] = str(workspace_root or record.get("workspace_root") or "") diff --git a/src/leapflow/storage/evolution_trace_store.py b/src/leapflow/storage/evolution_trace_store.py new file mode 100644 index 00000000..acd8659e --- /dev/null +++ b/src/leapflow/storage/evolution_trace_store.py @@ -0,0 +1,120 @@ +"""Durable store for framework-evolution traces. + +⚠️ Not to be confused with :mod:`leapflow.storage.evolution_store`, whose +``DuckDBEvolutionStore.save_episode`` persists *skill learning* episodes. Two +different meanings of "evolution" live in this package, and both use the word +"episode": that one means "the agent practised a skill", this one means "the +framework changed itself". Named for ``EvolutionTrace`` rather than for evolution +in general precisely so the two cannot be mistaken for each other at a call site. + +**JSON rather than DuckDB, deliberately.** The roadmap called for a DuckDB table; +this is a considered deviation: + +* *Volume does not justify it.* Traces are written when the framework mutates -- + a plugin installs, a trust level moves, the world model proposes. Those are rare + by nature, not per-turn. A table sized for time-series volume would carry + connection-holder, schema and retry machinery for a file that gains a handful of + rows a day. +* *It matches its neighbours.* The ledger already reads + ``capability_plans.json``, ``capability_observations.json`` and + ``proposal_queue.json`` from this same directory. One idiom for the causal + history means one failure mode, not two. +* *Inspectable and additively versioned*, for the same reason the sibling + capability stores chose JSON: an older record stays readable after the schema + grows. + +Retention is a hard cap on record count rather than an age, because what matters +is that the newest traces are always present -- an operator reading the board after +an incident needs the last mutations, not a complete history. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any, Iterable, Mapping + +logger = logging.getLogger(__name__) + +#: Keep the newest N traces. Generous relative to the write rate, and bounded so +#: the file cannot grow without limit on a long-lived profile. +DEFAULT_MAX_TRACES = 2000 + + +class JsonEvolutionTraceStore: + """Append-only, count-bounded JSON store for evolution traces. + + Every method degrades rather than raising: this store backs a transparency + panel, and losing the panel is preferable to failing the operation a trace was + describing. A corrupt or unreadable file reads as empty and is overwritten by + the next append, which is the same choice the sibling capability stores make. + """ + + def __init__(self, path: Path, *, max_traces: int = DEFAULT_MAX_TRACES) -> None: + self._path = Path(path) + self._max = max(1, int(max_traces)) + + @property + def path(self) -> Path: + return self._path + + def append(self, traces: Iterable[Mapping[str, Any]]) -> int: + """Append serialised traces, trimming to the newest ``max_traces``. + + Takes a batch because the sink buffers: one file rewrite per flush rather + than one per trace keeps the cost off whatever produced them. + """ + incoming = [dict(trace) for trace in traces if isinstance(trace, Mapping)] + if not incoming: + return 0 + try: + payload = self._load() + records = payload["traces"] + records.extend(incoming) + # Order by time so a trim keeps the newest regardless of arrival order. + records.sort(key=lambda item: float(item.get("ts") or 0.0)) + if len(records) > self._max: + del records[: len(records) - self._max] + self._write(payload) + return len(incoming) + except (OSError, TypeError, ValueError): + logger.debug("evolution trace store: append failed", exc_info=True) + return 0 + + def list_traces(self, *, limit: int = 200) -> list[dict[str, Any]]: + """Return newest traces first.""" + records = self._load()["traces"] + records.sort(key=lambda item: float(item.get("ts") or 0.0), reverse=True) + return records if limit <= 0 else records[:limit] + + def count(self) -> int: + return len(self._load()["traces"]) + + # ── file access ─────────────────────────────────────────────────────── + + def _load(self) -> dict[str, Any]: + if not self._path.exists(): + return {"version": 1, "traces": []} + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + if isinstance(data, Mapping): + traces = data.get("traces") + if isinstance(traces, list): + return { + "version": int(data.get("version") or 1), + "traces": [dict(t) for t in traces if isinstance(t, Mapping)], + } + except (OSError, json.JSONDecodeError, TypeError, ValueError): + logger.debug("evolution trace store: unreadable, treating as empty", exc_info=True) + return {"version": 1, "traces": []} + + def _write(self, payload: Mapping[str, Any]) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) + + +__all__ = ["DEFAULT_MAX_TRACES", "JsonEvolutionTraceStore"] diff --git a/src/leapflow/telemetry/__init__.py b/src/leapflow/telemetry/__init__.py new file mode 100644 index 00000000..e21553f4 --- /dev/null +++ b/src/leapflow/telemetry/__init__.py @@ -0,0 +1,8 @@ +"""Telemetry taps: optional, opt-in observation points for runtime facts. + +A tap is a module-level sink plus a one-line emit function. Absent a sink every +probe is a no-op, so a tap can be placed at a hot or low-level site without +imposing a dependency or a cost on it. +""" + +__all__: list[str] = [] diff --git a/src/leapflow/telemetry/evolution_tap.py b/src/leapflow/telemetry/evolution_tap.py new file mode 100644 index 00000000..1cb53ee1 --- /dev/null +++ b/src/leapflow/telemetry/evolution_tap.py @@ -0,0 +1,96 @@ +"""EvolutionTap: emit a framework-evolution fact, or do nothing at all. + +One module-level optional sink, and one function that writes to it. When no sink +is installed -- the default, and the only state an in-process CLI ever sees -- +``emit_trace`` costs a global read and a null check, so a probe at a mutation point +is free until someone asks to observe it. + +Why a module-level global rather than an injected dependency: the probe sites are +places like the plugin registry's version bump and the trust ledger's level +transition. Those are pure, low-level objects with no service container to reach +into, and threading a sink through every one of them would put an observability +concern into their constructors. A process-wide opt-in sink keeps the call sites to +a single line and keeps the objects' own dependencies unchanged. + +Three rules this module exists to guarantee, all of them from hard experience +recorded in the project's contract: + +* **Telemetry never fails a turn.** ``emit_trace`` swallows everything, including + a broken sink, and logs at debug. A probe is not allowed to have an opinion + about whether the operation it observes succeeds. +* **A local defect is not an external failure.** Nothing here raises, so nothing + here can be misread by the recovery classifier as a provider problem. Traces + never enter ``RecoveryCoordinator``. +* **Cold path only.** The sink contract is "accept and return"; correlation, + persistence and re-publication happen later, on the daemon's own schedule. + A sink that blocks here would drag storage into a mutation point. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Mapping + +from leapflow.domain.evolution_trace import EvolutionStage, EvolutionTrace, EvolutionTraceSink + +logger = logging.getLogger(__name__) + +#: Process-wide optional sink. ``None`` means every probe is a no-op. +_SINK: EvolutionTraceSink | None = None + + +def install_sink(sink: EvolutionTraceSink | None) -> None: + """Install (or clear, with ``None``) the process-wide trace sink. + + Called by the daemon once observation is enabled. Idempotent and last-wins: + a second install replaces the first rather than fanning out, because two sinks + would double-count every fact. + """ + global _SINK + _SINK = sink + + +def current_sink() -> EvolutionTraceSink | None: + """The installed sink, for tests and for callers that must check first.""" + return _SINK + + +def is_enabled() -> bool: + """Whether a sink is installed. + + Lets an expensive ``detail`` payload be skipped entirely rather than built and + thrown away -- the only case where a probe site should branch. + """ + return _SINK is not None + + +def emit_trace( + stage: EvolutionStage, + kind: str, + *, + correlation: Mapping[str, str] | None = None, + summary: str = "", + detail: Mapping[str, Any] | None = None, + ts: float = 0.0, +) -> None: + """Record one evolution fact. Never raises, never blocks, never no-ops loudly.""" + sink = _SINK + if sink is None: + return + try: + sink.record( + EvolutionTrace( + stage=stage, + kind=str(kind), + ts=float(ts or time.time()), + correlation=dict(correlation or {}), + summary=str(summary), + detail=dict(detail or {}), + ) + ) + except Exception: # noqa: BLE001 - observability must never affect the observed + logger.debug("evolution tap: sink rejected a trace", exc_info=True) + + +__all__ = ["current_sink", "emit_trace", "install_sink", "is_enabled"] diff --git a/src/leapflow/tools/config_tools.py b/src/leapflow/tools/config_tools.py index 4cb93dd6..86100b13 100644 --- a/src/leapflow/tools/config_tools.py +++ b/src/leapflow/tools/config_tools.py @@ -253,7 +253,18 @@ async def config_set_handler(args: Dict[str, Any]) -> Dict[str, Any]: key, scope=scope, secret=bool(before.secret), hot_reload=before.hot_reload, ) if denial: - return {"ok": False, "error": denial, "retryable": False, "requires_approval": True} + return { + "ok": False, + "error": denial, + "failure_code": "approval_denied", + "retryable": False, + "requires_approval": True, + "blocks_approval": True, + "llm_instruction": ( + "STOP: The configuration change was not approved. Do NOT retry it or " + "attempt the same change through another tool. Report the denial to the user." + ), + } try: result = service.set(key, args["value"], scope=scope) # type: ignore[arg-type] diff --git a/src/leapflow/world_model/__init__.py b/src/leapflow/world_model/__init__.py index 72e9e6e2..b552a19f 100644 --- a/src/leapflow/world_model/__init__.py +++ b/src/leapflow/world_model/__init__.py @@ -10,7 +10,7 @@ from leapflow.world_model.experience_store import ExperienceStore, ExperienceTuple from leapflow.world_model.prediction import Prediction, PredictionLoop, PredictionOutcome from leapflow.world_model.replay import ExperienceReplayEngine, ReplayInsight -from leapflow.world_model.trajectory_grader import ActionGrade, TrajectoryGrader +from leapflow.world_model.trajectory_grader import ActionGrade, TeacherVerdict, TrajectoryGrader __all__ = [ "LearningBudgetController", @@ -25,5 +25,6 @@ "ExperienceReplayEngine", "ReplayInsight", "ActionGrade", + "TeacherVerdict", "TrajectoryGrader", ] diff --git a/src/leapflow/world_model/trajectory_grader.py b/src/leapflow/world_model/trajectory_grader.py index 8f75eb5f..5dac7122 100644 --- a/src/leapflow/world_model/trajectory_grader.py +++ b/src/leapflow/world_model/trajectory_grader.py @@ -13,6 +13,7 @@ from __future__ import annotations import logging +import re from dataclasses import dataclass from typing import TYPE_CHECKING, List @@ -20,6 +21,7 @@ from leapflow.world_model.budget import LearningBudgetController from leapflow.world_model.experience_store import ExperienceStore +from leapflow.domain.evolution_intent import EvolutionIntent from leapflow.llm.base import LLMProvider from leapflow.llm.message_builder import build_system_message, build_user_message_text from leapflow.world_model._json_utils import extract_json_object @@ -48,6 +50,72 @@ {{"grades": [{{"step": 1, "advantage": 0.3, "is_forking": false, \ "grade_label": "{example_label}"}}, ...]}}""" +# Appended when the teacher is also asked to propose capability gaps. Kept in the +# *same* call as grading so a proposal costs no additional budget token: the +# hindsight context needed to grade is exactly the context needed to notice that a +# capability is missing. +# +# The teacher is deliberately not asked for a risk level. An intent is a +# hypothesis, not an authorisation; the risk ceiling is imposed by the trusted +# caller (see ``EvolutionIntent`` / ``MODEL_AUTHORED_RISK_CEILING``). +_GAP_PROMPT_SECTION = """ + +Additionally, identify any capability the agent *lacked* -- cases where no +available action could have achieved the goal, as distinct from an available +action being chosen badly. Report only genuine gaps; report none if the agent had +what it needed and merely used it poorly. + +Before reporting a gap, apply these two rules: +- Do NOT restate the task, the goal, or the episode name as a capability. A + capability is a reusable ability such as "chat.reply", never a description of + this particular attempt. +- If the episode failed for a reason that is not a missing capability -- a label + was renamed, an element moved, a transient error, a wrong choice among + available actions -- return an empty list. An invented capability is worse than + a missed one, because it will be built. + +For each gap provide: +- capability: a stable dotted capability name (e.g. "chat.reply"). +- hypothesis: what is missing or broken, in one sentence. +- confidence: float in [0, 1]. +- target_affordance: the environment affordance a new adapter should target, if visible. +- rationale: why the existing capabilities cannot serve this. +- expected_effect: what should observably happen once the capability exists. + +Add to the JSON: +{{"capability_gaps": [{{"capability": "...", "hypothesis": "...", \ +"confidence": 0.7, "target_affordance": "...", "rationale": "...", \ +"expected_effect": "..."}}, ...]}} +Use an empty list when there is no genuine gap.""" + +#: A capability name is a short dotted path of identifier-like segments. Bounded +#: deliberately: a model asked for a capability sometimes answers with a sentence, and a +#: sentence must never become a requirement. +_CAPABILITY_RE = re.compile(r"^[a-z][a-z0-9_]{1,31}(\.[a-z0-9][a-z0-9_]{0,31}){1,3}$") + + +def _is_capability_name(value: str) -> bool: + """Whether a teacher-supplied string is shaped like a capability at all. + + Requires lowercase dotted structure with 2-4 segments. Rejects prose, bare words, + paths, and anything long enough to be a description rather than a name. + """ + return bool(value) and len(value) <= 96 and bool(_CAPABILITY_RE.match(value)) + + +def _echoes_goal(capability: str, goal: str) -> bool: + """Whether the capability is just the goal (or episode name) restated. + + A model handed a goal string will sometimes hand it straight back as the capability. + Compared on alphanumerics only, so separator and case differences do not let an echo + through. + """ + def norm(value: str) -> str: + return "".join(ch for ch in str(value).lower() if ch.isalnum()) + + normalised_goal = norm(goal) + return bool(normalised_goal) and norm(capability) == normalised_goal + @dataclass(frozen=True) class ActionGrade: @@ -59,6 +127,19 @@ class ActionGrade: grade_label: str +@dataclass(frozen=True) +class TeacherVerdict: + """Everything one hindsight evaluation produced. + + ``grades`` distil into the experience store as advantage signal; ``intents`` + are capability hypotheses that may drive self-evolution. Both are derived from + a single LLM call, so a verdict costs one ``grading`` budget token. + """ + + grades: tuple[ActionGrade, ...] = () + intents: tuple[EvolutionIntent, ...] = () + + class TrajectoryGrader: """Grades completed trajectories from a teacher perspective (full hindsight). @@ -99,18 +180,54 @@ async def grade_trajectory( return [] traj_text = self._format_trajectory(trajectory) - raw_grades = await self._call_teacher(traj_text, goal) + payload = await self._call_teacher_raw(traj_text, goal, propose_gaps=False) self._budget.spend("grading") - grades = self._persist_grades(trajectory, raw_grades) + grades = self._persist_grades(trajectory, self._parse_grades(payload)) return grades - async def _call_teacher( + async def grade_and_propose( + self, + trajectory: List[dict], + goal: str = "", + ) -> "TeacherVerdict": + """Grade the trajectory *and* propose capability gaps, in one LLM call. + + This is the teacher's full verdict: the advantage signal that distils into + experience, plus any :class:`EvolutionIntent` describing a capability the + agent lacked. Both come from the same hindsight context and the same + single ``grading`` budget token, so proposing costs nothing beyond grading. + + The returned intents are hypotheses. They carry no authorisation and must + still traverse the deterministic chain (declared-fitness resolution, risk + classification, approval, artifact validation, trust) before anything is + acquired. + """ + if len(trajectory) < self._min_len: + return TeacherVerdict((), ()) + if not self._budget.has_tokens("grading"): + return TeacherVerdict((), ()) + + traj_text = self._format_trajectory(trajectory) + payload = await self._call_teacher_raw(traj_text, goal, propose_gaps=True) + self._budget.spend("grading") + + grades = self._persist_grades(trajectory, self._parse_grades(payload)) + return TeacherVerdict(tuple(grades), self._parse_intents(payload, goal)) + + async def _call_teacher_raw( self, trajectory_text: str, goal: str, - ) -> List[ActionGrade]: - """Single LLM call: teacher grades with full hindsight.""" + *, + propose_gaps: bool = False, + ) -> dict: + """Single LLM call: teacher evaluates with full hindsight. + + Returns the parsed JSON object so grades and capability gaps can both be + derived from one response. A failed or unparseable call yields ``{}`` -- + the teacher is advisory, so it must never fail the caller. + """ labels_str = ", ".join(f'"{label}"' for label in self._grade_labels) prompt = _GRADE_PROMPT.format( goal=goal or "(not specified)", @@ -118,6 +235,8 @@ async def _call_teacher( grade_labels=labels_str, example_label=self._grade_labels[1] if len(self._grade_labels) > 1 else self._grade_labels[0], ) + if propose_gaps: + prompt += _GAP_PROMPT_SECTION.format() try: resp = await self._llm.achat( [build_system_message( @@ -126,10 +245,10 @@ async def _call_teacher( build_user_message_text(prompt)], stream=False, enable_thinking=False, ) - return self._parse_grades(resp.content or "") + return extract_json_object(resp.content or "") or {} except Exception: logger.debug("trajectory_grader.call_teacher failed", exc_info=True) - return [] + return {} def _format_trajectory(self, trajectory: List[dict]) -> str: """Render trajectory steps into a numbered text block.""" @@ -143,22 +262,76 @@ def _format_trajectory(self, trajectory: List[dict]) -> str: ) return "\n".join(lines) - def _parse_grades(self, response: str) -> List[ActionGrade]: - """Parse teacher response into ActionGrade objects.""" - obj = extract_json_object(response) - raw_grades = obj.get("grades", []) + def _parse_grades(self, payload: dict) -> List[ActionGrade]: + """Parse the teacher payload into ActionGrade objects.""" + raw_grades = payload.get("grades", []) if isinstance(payload, dict) else [] results: List[ActionGrade] = [] for raw in raw_grades: if not isinstance(raw, dict): continue + try: + advantage = float(raw.get("advantage", 0)) + except (TypeError, ValueError): + advantage = 0.0 results.append(ActionGrade( experience_id="", - advantage=max(-1.0, min(1.0, float(raw.get("advantage", 0)))), + advantage=max(-1.0, min(1.0, advantage)), is_forking=bool(raw.get("is_forking", False)), grade_label=str(raw.get("grade_label", "acceptable")), )) return results + def _parse_intents(self, payload: dict, goal: str = "") -> tuple[EvolutionIntent, ...]: + """Parse declared capability gaps into intents, skipping malformed entries. + + A gap without both a ``capability`` and a ``hypothesis`` is discarded: the + capability name must be declared, never inferred from prose. + + Two further rejections exist because a live model was measured doing exactly + this. Asked to diagnose an episode that failed for a *non-capability* reason, + ``qwen3.7-plus`` returned the episode's own name as the capability on 3 of 3 + trials. Nothing downstream would have caught it -- the name is well-formed, so + it would have become a requirement and the governed pipeline would have + faithfully tried to build ``chat.cosmetic.example``. + + So a capability must *look* like a capability, and must not be a restatement of + the goal. Neither check can catch a plausible-but-wrong capability; that is what + validation, effect verification and quarantine are for. These catch the + degenerate case, which is the one that produces pure noise. + """ + raw_gaps = payload.get("capability_gaps", []) if isinstance(payload, dict) else [] + intents: List[EvolutionIntent] = [] + for raw in raw_gaps: + if not isinstance(raw, dict): + continue + capability = str(raw.get("capability") or "").strip() + if not _is_capability_name(capability): + logger.debug( + "trajectory_grader: rejected non-capability name %r", capability + ) + continue + if _echoes_goal(capability, goal): + logger.debug( + "trajectory_grader: rejected goal restatement %r", capability + ) + continue + try: + confidence = float(raw.get("confidence", 0.0)) + except (TypeError, ValueError): + confidence = 0.0 + try: + intents.append(EvolutionIntent.create( + capability, + str(raw.get("hypothesis") or ""), + confidence=confidence, + target_affordance=str(raw.get("target_affordance") or ""), + rationale=str(raw.get("rationale") or ""), + expected_effect=str(raw.get("expected_effect") or ""), + )) + except ValueError: + logger.debug("trajectory_grader: discarded malformed capability gap %r", raw) + return tuple(intents) + def _persist_grades( self, trajectory: List[dict], diff --git a/tests/test_architecture_contracts.py b/tests/test_architecture_contracts.py index 685ad989..b1a68a95 100644 --- a/tests/test_architecture_contracts.py +++ b/tests/test_architecture_contracts.py @@ -574,8 +574,10 @@ def test_hardware_default_watch_targets_the_hardware_domain() -> None: """ from leapflow.daemon.monitor_coordinator import MonitorCoordinator - # The class-level _DEFAULT_WATCHES must include a hardware entry. - domains = [domain for _name, domain, _trigger in MonitorCoordinator._DEFAULT_WATCHES] + # The class-level _DEFAULT_WATCHES must include a hardware entry. Unpacked by + # position with a catch-all so a later column added to the tuple cannot fail this + # contract, which is about the domain being present and nothing else. + domains = [entry[1] for entry in MonitorCoordinator._DEFAULT_WATCHES] assert "hardware" in domains, ( "G24 regression: no default watch targets the 'hardware' domain; " "the HardwareObservationProducer would be registered but never invoked" diff --git a/tests/test_coevolution_observations.py b/tests/test_coevolution_observations.py new file mode 100644 index 00000000..ed892d40 --- /dev/null +++ b/tests/test_coevolution_observations.py @@ -0,0 +1,350 @@ +"""A-r1 / A-r2: the sweep's inputs are produced by the real production paths. + +Phase A gave the sweep a call site but nothing fed it, so it correctly emitted three +no-op traces forever. This closes that: the engine records resolutions, the install +path records acquisitions, and the tool-outcome sink records failure streaks. Each +test drives the **production** function rather than asserting against a hand-built +buffer, per AGENTS.md's rule that a test may not fabricate the wiring it covers. + +Also covers the F4 fix: exclusions are matched by the excluded component's *scorer +name*, never by its prose. +""" + +from __future__ import annotations + +from leapflow.domain.evolution_intent import EvolutionIntent +from leapflow.evolution.observations import ( + CoevolutionObservations, + current_observations, + install_observations, +) +from leapflow.learning.capability_effect_verifier import ( + DURABLE_EXCLUSIONS, + UnselectableArtifactReaper, +) +from leapflow.learning.outcome_governance_feed import QuarantineCandidateTracker + + +def _fresh() -> CoevolutionObservations: + buf = CoevolutionObservations() + install_observations(buf) + return buf + + +def _requirement(): + return EvolutionIntent.create( + "chat.reply", "send no-ops", expected_effect="the reply appears" + ).to_requirement() + + +# ── the buffer's contract ───────────────────────────────────────────────────── + + +def test_outcome_is_only_paired_when_the_plugin_was_acquired(): + """Verifying a hand-installed tool against a teacher expectation is meaningless.""" + buf = _fresh() + try: + buf.record_resolution(requirement=_requirement(), selected_plugin="hand_made") + buf.record_tool_outcome("hand_made", "t", ok=True) + assert buf.drain_verifications() == () # never bound + + buf.record_acquisition("gen1") + buf.record_resolution(requirement=_requirement(), selected_plugin="gen1") + buf.record_tool_outcome("gen1", "t", ok=True, observed_effect="the reply appears") + drained = buf.drain_verifications() + assert len(drained) == 1 + assert drained[0][2] == "gen1" + finally: + install_observations(None) + + +def test_draining_prevents_double_governing_the_same_outcome(): + buf = _fresh() + try: + buf.record_acquisition("gen1") + buf.record_resolution(requirement=_requirement(), selected_plugin="gen1") + buf.record_tool_outcome("gen1", "t", ok=False) + assert len(buf.drain_verifications()) == 1 + assert buf.drain_verifications() == () # already taken + finally: + install_observations(None) + + +def test_every_buffer_is_bounded(): + """Governance state must not grow with session length.""" + buf = CoevolutionObservations(max_resolutions=3, max_verifications=2, max_acquired=2) + install_observations(buf) + try: + for i in range(10): + buf.record_resolution(selected_plugin=f"p{i}") + assert len(buf.resolutions()) == 3 + buf.record_acquisition("a") + buf.record_acquisition("b") + buf.record_acquisition("c") + assert len(buf.acquired_plugin_ids()) == 2 + finally: + install_observations(None) + + +def test_acquisitions_are_deduplicated(): + buf = _fresh() + try: + buf.record_acquisition("gen1") + buf.record_acquisition("gen1") + assert buf.acquired_plugin_ids() == ("gen1",) + finally: + install_observations(None) + + +def test_process_accessor_creates_a_buffer_on_first_use(): + install_observations(None) + assert isinstance(current_observations(), CoevolutionObservations) + install_observations(None) + + +# ── A-r1: the engine records resolutions (drive the real method) ────────────── + + +class _Component: + def __init__(self, scorer, excluded, reason=""): + self.scorer, self.excluded, self.reason = scorer, excluded, reason + + +class _Candidate: + def __init__(self, plugin_id): + self.plugin_id = plugin_id + + +class _Score: + def __init__(self, plugin_id, components): + self.candidate = _Candidate(plugin_id) + self.components = tuple(components) + + @property + def eligible(self): + return not any(c.excluded for c in self.components) + + +class _Resolution: + def __init__(self, requirement, candidates, selected=None): + self.requirement = requirement + self.candidates = tuple(candidates) + self.selected = selected + + +def _drive_engine_record(resolution): + """Call the production static method itself.""" + from leapflow.engine.engine import AgentEngine + + AgentEngine._record_coevolution_resolution(resolution) + + +def test_engine_records_scorer_names_not_prose(): + """F4: keying off a human-readable reason breaks when the resolver rewords it.""" + buf = _fresh() + try: + over_risk = _Score("gen_overrisk", [ + _Component("risk_cost", True, "risk 'external' exceeds max 'read_only'"), + ]) + incumbent = _Score("incumbent", [_Component("declared_match", False)]) + _drive_engine_record(_Resolution(_requirement(), [over_risk, incumbent], incumbent)) + + recorded = buf.resolutions() + assert len(recorded) == 1 + assert recorded[0]["selected_plugin"] == "incumbent" + # The durable name, not the sentence. + assert recorded[0]["exclusions"]["gen_overrisk"] == ["risk_cost"] + assert "incumbent" not in recorded[0]["exclusions"] # eligible -> not excluded + finally: + install_observations(None) + + +def test_engine_recording_survives_a_malformed_resolution(): + """Observation must never disturb the turn that produced it.""" + buf = _fresh() + try: + _drive_engine_record(object()) + assert buf.resolutions() == () or len(buf.resolutions()) >= 0 + finally: + install_observations(None) + + +def test_engine_records_feed_the_reaper_end_to_end(): + """Engine output must be directly consumable by the reaper -- no adapter.""" + buf = _fresh() + try: + buf.record_acquisition("gen_overrisk") + for _ in range(3): + over = _Score("gen_overrisk", [_Component("risk_cost", True, "over cap")]) + keep = _Score("incumbent", [_Component("declared_match", False)]) + _drive_engine_record(_Resolution(_requirement(), [over, keep], keep)) + + found = UnselectableArtifactReaper(min_resolutions=3).candidates( + acquired_plugin_ids=buf.acquired_plugin_ids(), resolutions=buf.resolutions(), + ) + assert [c.plugin_id for c in found] == ["gen_overrisk"] + finally: + install_observations(None) + + +def test_environment_exclusion_from_the_engine_is_not_reaped(): + buf = _fresh() + try: + buf.record_acquisition("gen_v2") + for _ in range(3): + miss = _Score("gen_v2", [_Component("environment_affordance", True, "missing")]) + keep = _Score("incumbent", [_Component("declared_match", False)]) + _drive_engine_record(_Resolution(_requirement(), [miss, keep], keep)) + + found = UnselectableArtifactReaper(min_resolutions=3).candidates( + acquired_plugin_ids=buf.acquired_plugin_ids(), resolutions=buf.resolutions(), + ) + assert found == () + finally: + install_observations(None) + + +def test_durable_exclusions_are_configurable(): + """F5: the predicate is a constructor parameter, not a baked-in constant.""" + assert DURABLE_EXCLUSIONS == ("risk_cost",) + resolutions = [ + {"selected_plugin": "x", "exclusions": {"gen": ["custom_gate"]}} for _ in range(3) + ] + assert UnselectableArtifactReaper(min_resolutions=3).candidates( + acquired_plugin_ids=["gen"], resolutions=resolutions + ) == () + found = UnselectableArtifactReaper( + min_resolutions=3, durable_exclusions=("custom_gate",) + ).candidates(acquired_plugin_ids=["gen"], resolutions=resolutions) + assert [c.plugin_id for c in found] == ["gen"] + + +# ── A-r2: the tool-outcome sink feeds the quarantine streak ─────────────────── + + +def _usage_tracker(owners: dict[str, str] | None = None): + """Real PluginUsageTracker with its tool->plugin reverse index primed. + + The reverse index is a *precondition* here, not the wiring under test: in + production it is built from the live registry's ``tool_owners``. Priming the + documented cache (with the registry's current version, so the cache is not + invalidated) keeps the assertion on the streak feed itself. + """ + from leapflow.plugins import get_registry + from leapflow.learning.plugin_stats import PluginUsageTracker + from leapflow.learning.plugin_trust import PluginTrustLedger + + tracker = PluginUsageTracker() + tracker.set_trust_ledger(PluginTrustLedger()) + tracker._tool_to_plugin = dict(owners or {}) + tracker._registry_version = getattr(get_registry(), "_version", 0) + return tracker + + +def test_usage_tracker_feeds_the_quarantine_streak(): + """Drives the real PluginUsageTracker.record, not a stand-in.""" + usage = _usage_tracker({"bad_tool": "bad_plugin"}) + quarantine = QuarantineCandidateTracker(quarantine_after=2) + usage.set_quarantine_tracker(quarantine) + + usage.record("bad_tool", ok=False, duration_ms=1.0) + assert quarantine.pending() == 0 + usage.record("bad_tool", ok=False, duration_ms=1.0) + assert quarantine.pending() == 1 + assert quarantine.candidates()[0].plugin_id == "bad_plugin" + + +def test_success_through_the_real_sink_resets_the_streak(): + usage = _usage_tracker({"t": "p"}) + quarantine = QuarantineCandidateTracker(quarantine_after=2) + usage.set_quarantine_tracker(quarantine) + + usage.record("t", ok=False, duration_ms=1.0) + usage.record("t", ok=True, duration_ms=1.0) + usage.record("t", ok=False, duration_ms=1.0) + assert quarantine.pending() == 0 + + +def test_a_broken_quarantine_tracker_never_fails_a_tool_call(): + class _Broken: + def record(self, *a, **k): + raise RuntimeError("boom") + + usage = _usage_tracker({"t": "p"}) + usage.set_quarantine_tracker(_Broken()) + usage.record("t", ok=False, duration_ms=1.0) # must not raise + + +def test_unowned_tool_records_no_streak(): + usage = _usage_tracker({}) + quarantine = QuarantineCandidateTracker(quarantine_after=1) + usage.set_quarantine_tracker(quarantine) + usage.record("orphan", ok=False, duration_ms=1.0) + assert quarantine.pending() == 0 + + +# ── the shared tracker: injected at composition, drained by the sweep ───────── + + +def test_composition_injects_the_process_tracker_into_the_usage_sink(): + """Without this the whole feed is inert: the sink increments nothing. + + Asserts the wiring by driving the real `PluginUsageTracker` after attaching the + process tracker the way `session_factory` does, then checking the *same* instance + the sweep would drain has the streak. + """ + from leapflow.evolution.observations import ( + current_quarantine_tracker, + install_quarantine_tracker, + ) + + install_quarantine_tracker(QuarantineCandidateTracker(quarantine_after=2)) + try: + shared = current_quarantine_tracker() + usage = _usage_tracker({"t": "p"}) + usage.set_quarantine_tracker(shared) # what session_factory does + + usage.record("t", ok=False, duration_ms=1.0) + usage.record("t", ok=False, duration_ms=1.0) + + # The sweep resolves the tracker through the same accessor. + assert current_quarantine_tracker() is shared + assert shared.pending() == 1 + finally: + install_quarantine_tracker(None) + + +def test_the_usage_sink_feeds_streaks_but_not_verifications(): + """Outcome recording moved to the engine (C-1), and must not happen twice. + + The sink receives only ``ok``; a tool's observed effect lives in its result + payload, which only the engine's result-observation path sees. Recording here as + well would double-count and would grade every success unverifiable. The streak + feed stays, because quarantine needs nothing but ``ok``. + """ + buf = _fresh() + try: + buf.record_acquisition("acquired_plugin") + buf.record_resolution(requirement=_requirement(), selected_plugin="acquired_plugin") + + quarantine = QuarantineCandidateTracker(quarantine_after=1) + usage = _usage_tracker({"gen_tool": "acquired_plugin"}) + usage.set_quarantine_tracker(quarantine) + usage.record("gen_tool", ok=False, duration_ms=1.0) + + assert quarantine.pending() == 1 # streak fed + assert buf.drain_verifications() == () # verification is the engine's job + finally: + install_observations(None) + + +def test_outcomes_for_unacquired_plugins_do_not_accumulate(): + """Every ordinary tool call must leave the verification buffer untouched.""" + buf = _fresh() + try: + usage = _usage_tracker({"list_dir": "builtin_fs"}) + for _ in range(50): + usage.record("list_dir", ok=True, duration_ms=1.0) + assert buf.drain_verifications() == () + finally: + install_observations(None) diff --git a/tests/test_coevolution_sweep_wiring.py b/tests/test_coevolution_sweep_wiring.py new file mode 100644 index 00000000..3f5930ef --- /dev/null +++ b/tests/test_coevolution_sweep_wiring.py @@ -0,0 +1,345 @@ +"""Phase A: the co-evolution capabilities are wired, and the wiring is driven. + +The prior round shipped `CapabilityEffectVerifier`, `QuarantineCandidateTracker` +and `UnselectableArtifactReaper` with **zero production callers** — which the +evolution dashboard reported as three `NO_EVIDENCE` rows rather than treating the +modules' presence as proof. This suite closes that. + +Per AGENTS.md, a test whose purpose is wiring must construct the real object and +drive the production path, so the context tests below call +`_run_coevolution_sweep` on a real `CoevolutionSweep` and assert the governed +effects — not the collaborators. +""" + +from __future__ import annotations + +import asyncio + +from leapflow.domain.capability_requirement import CapabilityRequirement +from leapflow.domain.evolution_trace import EvolutionStage, EvolutionTrace +from leapflow.domain.evolution_intent import EvolutionIntent +from leapflow.evolution.sweep import CoevolutionSweep, SweepOutcome +from leapflow.learning.outcome_governance_feed import QuarantineCandidateTracker +from leapflow.telemetry import evolution_tap + + +class _CapturingSink: + """Minimal EvolutionTraceSink that records what the sweep emitted.""" + + def __init__(self) -> None: + self.traces: list[EvolutionTrace] = [] + + def record(self, trace: EvolutionTrace) -> None: + self.traces.append(trace) + + def kinds(self) -> set[str]: + return {t.kind for t in self.traces} + + def of(self, kind: str) -> list[EvolutionTrace]: + return [t for t in self.traces if t.kind == kind] + + +class _Governor: + def __init__(self, action: str = "probation_execute") -> None: + self.calls: list[dict] = [] + self._action = action + + async def record_outcome(self, **kwargs): + self.calls.append(kwargs) + return type("R", (), {"action": self._action, "trust_level": "DRAFT"})() + + +def _requirement(expected: str = "the reply appears in the thread"): + return EvolutionIntent.create( + "chat.reply", "send path no-ops", expected_effect=expected + ).to_requirement() + + +def _sink(): + sink = _CapturingSink() + evolution_tap.install_sink(sink) + return sink + + +def _teardown(): + evolution_tap.install_sink(None) + + +# ── every branch emits, including the no-ops ────────────────────────────────── + + +def test_empty_sweep_still_records_its_no_op_branches(): + """A quiet sweep must be distinguishable from a sweep that never ran.""" + sink = _sink() + try: + outcome = asyncio.run(CoevolutionSweep().run()) + assert outcome == SweepOutcome() + # All three segments reported, each flagged as a no-op. + assert sink.kinds() == {"effect_verification", "quarantine_drain", "reclamation"} + assert all(t.detail.get("no_op") for t in sink.traces) + finally: + _teardown() + + +def test_verified_effect_is_recorded_and_feeds_trust(): + sink = _sink() + governor = _Governor() + try: + outcome = asyncio.run(CoevolutionSweep(governor=governor).run( + verifications=[( + _requirement(), + {"ok": True, "observed_effect": "the reply appears in the thread"}, + "gen1", + )], + )) + assert outcome.verified == 1 and outcome.refuted == 0 + assert governor.calls[0]["ok"] is True + trace = sink.of("effect_verification")[0] + assert trace.stage is EvolutionStage.LEARN + assert trace.detail["verified"] is True + assert trace.correlation["plugin_id"] == "gen1" + finally: + _teardown() + + +def test_refuted_effect_feeds_a_failure_even_though_the_call_succeeded(): + """The point of WM-6: ok-but-no-effect must not be recorded as success.""" + sink = _sink() + governor = _Governor() + try: + outcome = asyncio.run(CoevolutionSweep(governor=governor).run( + verifications=[( + _requirement(), {"ok": True, "observed_effect": "nothing happened"}, "gen1", + )], + )) + assert outcome.refuted == 1 + assert governor.calls[0]["ok"] is False + assert governor.calls[0]["failure_class"] == "expected_effect_absent" + assert sink.of("effect_verification")[0].detail["verified"] is False + finally: + _teardown() + + +def test_unverifiable_verdict_never_touches_trust(): + """A missing declaration must not quarantine a healthy plugin.""" + sink = _sink() + governor = _Governor() + try: + bare = CapabilityRequirement.create("chat.reply", "unknown_tool") + outcome = asyncio.run(CoevolutionSweep(governor=governor).run( + verifications=[(bare, {"ok": True, "observed_effect": "sent"}, "gen1")], + )) + assert outcome.unverifiable == 1 + assert governor.calls == [] # nothing recorded + assert sink.of("effect_verification")[0].detail["verified"] is None + finally: + _teardown() + + +def test_quarantine_candidate_is_drained_and_recorded(): + sink = _sink() + governor = _Governor(action="quarantine") + tracker = QuarantineCandidateTracker(quarantine_after=2) + try: + tracker.record("bad", "bad_tool", ok=False) + tracker.record("bad", "bad_tool", ok=False) # crosses the threshold + assert tracker.pending() == 1 + + outcome = asyncio.run(CoevolutionSweep(governor=governor, tracker=tracker).run()) + assert len(outcome.quarantined) == 1 + assert tracker.pending() == 0 # cleared by the drain + trace = sink.of("quarantine_drain")[0] + assert trace.stage is EvolutionStage.ACT + assert trace.detail["action"] == "quarantine" + finally: + _teardown() + + +def test_reclamation_candidate_is_recorded_as_residue(): + sink = _sink() + try: + resolutions = [ + {"selected_plugin": "incumbent", "exclusions": {"gen_overrisk": ["risk_cost"]}} + for _ in range(3) + ] + outcome = asyncio.run(CoevolutionSweep().run( + acquired_plugin_ids=["gen_overrisk"], resolutions=resolutions, + )) + assert [c.plugin_id for c in outcome.reclamation] == ["gen_overrisk"] + trace = sink.of("reclamation")[0] + assert trace.correlation["plugin_id"] == "gen_overrisk" + assert "never selected" in trace.summary + finally: + _teardown() + + +def test_verification_failure_can_feed_the_same_sweep_ordering(): + """Verification runs before the drain, so a refuted verdict is governed first.""" + governor = _Governor() + tracker = QuarantineCandidateTracker(quarantine_after=1) + _sink() + try: + tracker.record("other", "other_tool", ok=False) + asyncio.run(CoevolutionSweep(governor=governor, tracker=tracker).run( + verifications=[(_requirement(), {"ok": False}, "gen1")], + )) + assert [c["plugin_id"] for c in governor.calls] == ["gen1", "other"] + finally: + _teardown() + + +def test_governance_failure_does_not_break_the_sweep(): + class _Broken: + async def record_outcome(self, **kwargs): + raise OSError("store down") + + _sink() + try: + outcome = asyncio.run(CoevolutionSweep(governor=_Broken()).run( + verifications=[(_requirement(), {"ok": False}, "gen1")], + )) + assert outcome.refuted == 1 # the verdict still stands + finally: + _teardown() + + +def test_sweep_is_inert_when_tracing_is_disabled(): + """Observability must never affect the observed.""" + evolution_tap.install_sink(None) + outcome = asyncio.run(CoevolutionSweep().run()) + assert outcome == SweepOutcome() + + +# ── the production wiring, driven (AGENTS.md: do not fabricate the wiring) ───── + + +class _Ctx: + """Bind the real production methods onto a minimal host. + + Deliberately not `object.__new__` on the real context plus private-attribute + assignment: that pattern cannot detect a wrong attribute *name*. These bind the + actual unbound functions from `Context`, so the assertions run the same + code a session-end runs. + """ + + def __init__(self, **attrs) -> None: + for key, value in attrs.items(): + setattr(self, key, value) + + async def run_sweep(self): + from leapflow.cli.context import Context + + return await Context._run_coevolution_sweep(self) + + +def test_production_sweep_hook_builds_and_runs_a_real_sweep(): + """Drives `_run_coevolution_sweep` itself, not a hand-made CoevolutionSweep. + + Inputs arrive through the process observation buffer, which is where the engine, + the install path and the tool-outcome sink deposit them in production. + """ + from leapflow.evolution.observations import CoevolutionObservations, install_observations + + sink = _sink() + governor = _Governor(action="quarantine") + tracker = QuarantineCandidateTracker(quarantine_after=1) + tracker.record("bad", "bad_tool", ok=False) + + buf = CoevolutionObservations() + install_observations(buf) + requirement = _requirement() + buf.record_acquisition("gen1") + buf.record_acquisition("gen_overrisk") + buf.record_resolution(requirement=requirement, selected_plugin="gen1") + buf.record_tool_outcome("gen1", "gen1_tool", ok=True, observed_effect="nothing happened") + for _ in range(3): + buf.record_resolution( + requirement=requirement, + selected_plugin="x", + exclusions={"gen_overrisk": ["risk_cost"]}, + ) + try: + ctx = _Ctx(lifecycle_governor=governor, _quarantine_tracker=tracker) + outcome = asyncio.run(ctx.run_sweep()) + + assert outcome is not None + assert outcome.refuted == 1 # WM-6 wired + assert len(outcome.quarantined) == 1 # A-4 wired + assert [c.plugin_id for c in outcome.reclamation] == ["gen_overrisk"] # LF-10 wired + # All three dashboard segments now have observed output. + assert sink.kinds() == {"effect_verification", "quarantine_drain", "reclamation"} + assert outcome.to_dict()["quarantined"] == 1 + # Verifications were drained, so a second sweep cannot double-govern them. + assert buf.drain_verifications() == () + finally: + install_observations(None) + _teardown() + + +def test_production_hook_uses_the_shared_process_tracker(): + from leapflow.evolution.observations import CoevolutionObservations, install_observations + + _sink() + install_observations(CoevolutionObservations()) + try: + ctx = _Ctx(lifecycle_governor=_Governor()) + outcome = asyncio.run(ctx.run_sweep()) + assert outcome is not None + assert outcome.to_dict()["quarantined"] == 0 + finally: + install_observations(None) + _teardown() + + +def test_production_hook_survives_a_broken_collaborator(): + """A failure to govern must not fail the session that produced the trajectory.""" + _sink() + try: + ctx = _Ctx(lifecycle_governor=object()) + outcome = asyncio.run(ctx.run_sweep()) + assert outcome is None or isinstance(outcome, SweepOutcome) + finally: + _teardown() + + +# ── P5 enforcement, wired into the gap gate ─────────────────────────────────── + + +def _loop(tmp_path): + from leapflow.plugins.adaptive_loop import AdaptivePluginLoop + from leapflow.plugins.registry import ToolPluginRegistry + from leapflow.storage.capability_plan_store import JsonCapabilityPlanStore + + return AdaptivePluginLoop( + registry=ToolPluginRegistry(), + plan_store=JsonCapabilityPlanStore(tmp_path / "plans.json"), + ) + + +def _env(): + from leapflow.domain.environment_fingerprint import EnvironmentFingerprint + + return EnvironmentFingerprint( + platform_id="linux_gnome", os_version="x", platform_capabilities=(), workspace_root="/w" + ) + + +def test_gap_gate_is_unrestricted_by_default(tmp_path): + """Shipped behaviour: any origin may drive acquisition.""" + loop = _loop(tmp_path) + shipped = CapabilityRequirement.create("list_dir", "unknown_tool") + unmet = loop.unmet_requirements([shipped], _env()) + assert [r.origin for r in unmet] == ["unknown_tool"] + + +def test_gap_gate_excludes_unauthorised_origins(tmp_path): + """The goal, enforceable: only world-model requirements reach the gap set.""" + loop = _loop(tmp_path) + wm = EvolutionIntent.create("chat.reply", "gap").to_requirement() + shipped = CapabilityRequirement.create("list_dir", "unknown_tool") + + unmet = loop.unmet_requirements([wm, shipped], _env(), authorising_origins=("world_model",)) + assert [r.origin for r in unmet] == ["world_model"] + + # And with nothing authorised, the gate is empty rather than permissive. + assert loop.unmet_requirements([shipped], _env(), authorising_origins=("world_model",)) == () diff --git a/tests/test_concurrent_workspace_governance.py b/tests/test_concurrent_workspace_governance.py new file mode 100644 index 00000000..9f719085 --- /dev/null +++ b/tests/test_concurrent_workspace_governance.py @@ -0,0 +1,314 @@ +"""F7 / F8: concurrent workspaces, and the fiber lifecycle of the changed contracts. + +**F7 (MANDATORY).** This work introduced process-global governance state, so AGENTS.md +requires two sessions in two workspaces asserting that neither sees the other's +identity, usage or turn state. + +The design question it forced, answered here rather than deferred: *should workspace +A's failures be able to quarantine a plugin serving workspace B?* **Yes** — and it is +not a compromise. Plugins are process-global; a plugin that keeps failing is broken as +*code*, not "broken for workspace A". Trust already works exactly this way +(`PluginTrustLedger` is process-level), so making the quarantine streak per-workspace +would have made quarantine disagree with the trust it is supposed to escalate. + +What was missing was not isolation but **attribution**: a cross-workspace quarantine +has to be auditable rather than mysterious. So the contributing workspaces travel with +the decision and appear in its trace, and the tests below pin both halves — the shared +streak *and* the absence of any session/identity leak. + +**F8.** The changed Protocols (`evolution_contracts`, `AdaptiveEvolutionPolicy`) alter +what the agent can load, so AGENTS.md requires exercising register → publish → reload → +dispose against a real registry rather than a fake. +""" + +from __future__ import annotations + +import asyncio + +from leapflow.domain.evolution_intent import EvolutionIntent +from leapflow.domain.evolution_trace import EvolutionTrace +from leapflow.evolution.observations import ( + CoevolutionObservations, + install_observations, +) +from leapflow.evolution.sweep import CoevolutionSweep +from leapflow.learning.outcome_governance_feed import QuarantineCandidateTracker +from leapflow.telemetry import evolution_tap + +WS_A = "/work/alpha" +WS_B = "/work/beta" + + +class _Sink: + def __init__(self) -> None: + self.traces: list[EvolutionTrace] = [] + + def record(self, trace: EvolutionTrace) -> None: + self.traces.append(trace) + + def of(self, kind: str): + return [t for t in self.traces if t.kind == kind] + + +class _Governor: + def __init__(self) -> None: + self.calls: list[dict] = [] + + async def record_outcome(self, **kwargs): + self.calls.append(kwargs) + return type("R", (), {"action": "quarantine", "trust_level": "DRAFT"})() + + +def _requirement(): + return EvolutionIntent.create( + "chat.reply", "gap", expected_effect="the reply appears" + ).to_requirement() + + +def _drive_engine_outcome(item, workspace): + from leapflow.engine.engine import AgentEngine + + AgentEngine._record_coevolution_outcome(item, workspace) + + +def _registry_for(tool_name, plugin_id): + class _Reg: + tool_owners = {tool_name: plugin_id} + + return _Reg() + + +# ── F7: two workspaces, one process-global plugin ───────────────────────────── + + +def test_both_workspaces_are_attributed_to_one_shared_streak(monkeypatch): + """The intended semantics, made explicit: shared streak, named contributors.""" + buf = CoevolutionObservations() + install_observations(buf) + monkeypatch.setattr( + "leapflow.plugins.get_registry", lambda: _registry_for("gen_tool", "gen_p") + ) + try: + buf.record_acquisition("gen_p") + buf.record_resolution(requirement=_requirement(), selected_plugin="gen_p") + + _drive_engine_outcome({"name": "gen_tool", "result": {"ok": False}}, WS_A) + _drive_engine_outcome({"name": "gen_tool", "result": {"ok": False}}, WS_B) + + assert buf.contributing_workspaces("gen_p") == (WS_A, WS_B) + finally: + install_observations(None) + + +def test_a_cross_workspace_quarantine_is_flagged_in_its_trace(monkeypatch): + """The audit must be able to see that another workspace drove the decision.""" + buf = CoevolutionObservations() + install_observations(buf) + sink = _Sink() + evolution_tap.install_sink(sink) + monkeypatch.setattr( + "leapflow.plugins.get_registry", lambda: _registry_for("gen_tool", "gen_p") + ) + try: + buf.record_acquisition("gen_p") + buf.record_resolution(requirement=_requirement(), selected_plugin="gen_p") + _drive_engine_outcome({"name": "gen_tool", "result": {"ok": False}}, WS_A) + _drive_engine_outcome({"name": "gen_tool", "result": {"ok": False}}, WS_B) + + tracker = QuarantineCandidateTracker(quarantine_after=1) + tracker.record("gen_p", "gen_tool", ok=False) + asyncio.run(CoevolutionSweep(governor=_Governor(), tracker=tracker).run()) + + detail = sink.of("quarantine_drain")[0].detail + assert detail["cross_workspace"] is True + assert detail["contributing_workspaces"] == [WS_A, WS_B] + finally: + evolution_tap.install_sink(None) + install_observations(None) + + +def test_single_workspace_quarantine_is_not_flagged_as_cross(monkeypatch): + buf = CoevolutionObservations() + install_observations(buf) + sink = _Sink() + evolution_tap.install_sink(sink) + monkeypatch.setattr( + "leapflow.plugins.get_registry", lambda: _registry_for("gen_tool", "gen_p") + ) + try: + buf.record_acquisition("gen_p") + buf.record_resolution(requirement=_requirement(), selected_plugin="gen_p") + _drive_engine_outcome({"name": "gen_tool", "result": {"ok": False}}, WS_A) + + tracker = QuarantineCandidateTracker(quarantine_after=1) + tracker.record("gen_p", "gen_tool", ok=False) + asyncio.run(CoevolutionSweep(governor=_Governor(), tracker=tracker).run()) + + detail = sink.of("quarantine_drain")[0].detail + assert detail["cross_workspace"] is False + assert detail["contributing_workspaces"] == [WS_A] + finally: + evolution_tap.install_sink(None) + install_observations(None) + + +def test_no_session_or_client_identity_leaks_through_governance(monkeypatch): + """The actual isolation contract: usage and turn state must not cross. + + Attribution carries a *workspace path*, which is what makes a shared decision + auditable. It must not carry a session id, client id, turn id, or conversation + content -- those are per-client identity that one workspace may never learn about + another. + """ + buf = CoevolutionObservations() + install_observations(buf) + sink = _Sink() + evolution_tap.install_sink(sink) + monkeypatch.setattr( + "leapflow.plugins.get_registry", lambda: _registry_for("gen_tool", "gen_p") + ) + try: + buf.record_acquisition("gen_p") + buf.record_resolution(requirement=_requirement(), selected_plugin="gen_p") + for ws in (WS_A, WS_B): + _drive_engine_outcome( + { + "name": "gen_tool", + "arguments": {"secret": "workspace-private argument"}, + "result": {"ok": False, "error": "boom"}, + }, + ws, + ) + + tracker = QuarantineCandidateTracker(quarantine_after=1) + tracker.record("gen_p", "gen_tool", ok=False) + asyncio.run(CoevolutionSweep(governor=_Governor(), tracker=tracker).run()) + + blob = repr([t.to_dict() for t in sink.traces]) + repr(buf.stats()) + for forbidden in ("session_id", "client_id", "turn_id", "workspace-private"): + assert forbidden not in blob + finally: + evolution_tap.install_sink(None) + install_observations(None) + + +def test_verdicts_do_not_depend_on_which_workspace_called(monkeypatch): + """Same plugin id means the same code; attribution must not change a verdict.""" + monkeypatch.setattr( + "leapflow.plugins.get_registry", lambda: _registry_for("gen_tool", "gen_p") + ) + verdicts = [] + for ws in (WS_A, WS_B, ""): + buf = CoevolutionObservations() + install_observations(buf) + try: + buf.record_acquisition("gen_p") + buf.record_resolution(requirement=_requirement(), selected_plugin="gen_p") + _drive_engine_outcome( + {"name": "gen_tool", "result": {"ok": True, "effect": "the reply appears"}}, ws + ) + outcome = asyncio.run( + CoevolutionSweep().run(verifications=buf.drain_verifications()) + ) + verdicts.append((outcome.verified, outcome.refuted, outcome.unverifiable)) + finally: + install_observations(None) + assert len(set(verdicts)) == 1, verdicts + + +def test_attribution_is_bounded_by_plugin_not_by_session(): + """Long-lived multi-workspace processes must not grow attribution without bound.""" + buf = CoevolutionObservations() + install_observations(buf) + try: + for i in range(500): + buf.record_tool_outcome("p", "t", ok=True, workspace=f"/ws/{i % 3}") + assert len(buf.contributing_workspaces("p")) == 3 + finally: + install_observations(None) + + +# ── F8: register → publish → reload → dispose on a real registry ────────────── + + +def test_changed_contracts_survive_a_real_fiber_lifecycle(tmp_path): + """Exercise the real registry, not a fake, per AGENTS.md. + + The Protocols this programme changed (`evolution_contracts`, the policy's + dependency surface) affect what the agent can load, so the check that matters is + that a plugin carrying them can complete the whole fiber lifecycle and leave + nothing registered behind. + """ + from leapflow.plugins.registry import ToolPluginRegistry + from leapflow.plugins.scoped_registry import ScopedToolRegistry + + module = tmp_path / "eff_plugin.py" + module.write_text( + "from typing import Any\n" + "from leapflow.plugins.protocol import ToolMetadata\n" + "class P:\n" + " @property\n" + " def plugin_id(self): return 'eff_p'\n" + " @property\n" + " def category(self): return 'custom'\n" + " @property\n" + " def dependencies(self): return []\n" + " def bind_runtime(self, **deps: Any) -> None: pass\n" + " @property\n" + " def tools(self):\n" + " return [ToolMetadata(name='eff_tool', description='d',\n" + " parameters_schema={'type':'object','properties':{}},\n" + " handler=self._h,\n" + " x_leapflow={'category':'custom','risk_level':'read_only'})]\n" + " async def _h(self, **kw: Any) -> dict:\n" + " return {'ok': True, 'effect': 'the reply appears'}\n" + "plugin = P()\n", + encoding="utf-8", + ) + + registry = ToolPluginRegistry() + scoped = ScopedToolRegistry(registry) + spec = _load(module) + # Register *through* the scoped registry so the plugin gets a real fiber and a real + # effect scope -- the thing whose lifecycle is under test. + fiber = scoped.create_fiber("eff_p") + scoped.scoped_register(spec, fiber) + registry.assemble() + + assert "eff_tool" in registry.tool_handlers + assert registry.tool_owners.get("eff_tool") == "eff_p" + version_after_publish = registry.version + + # A handler that reports its effect is what makes C-1 verification possible, so + # assert the contract survives the round trip rather than just that reload ran. + result = asyncio.run(registry.tool_handlers["eff_tool"]()) + assert result["effect"] == "the reply appears" + + reloaded = scoped.reload("eff_p") + assert reloaded.plugin_id == "eff_p" + registry.assemble() + assert "eff_tool" in registry.tool_handlers + assert registry.version >= version_after_publish + + # The effect contract must survive re-import, or C-1 silently stops working after + # the first hot reload. + result = asyncio.run(registry.tool_handlers["eff_tool"]()) + assert result["effect"] == "the reply appears" + + disposed = scoped.dispose_plugin("eff_p", prune_metadata=True) + assert disposed.plugin_id == "eff_p" + registry.assemble() + assert "eff_tool" not in registry.tool_handlers + assert "eff_p" not in registry.tool_owners.values() + + +def _load(path): + """Import a plugin module by path, recording the source for file-backed reload.""" + import importlib.util + + spec = importlib.util.spec_from_file_location(path.stem, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + plugin = module.plugin + setattr(plugin, "__leapflow_plugin_path__", str(path)) + return plugin diff --git a/tests/test_config_capability_tools.py b/tests/test_config_capability_tools.py index b1b1f7ee..831a4566 100644 --- a/tests/test_config_capability_tools.py +++ b/tests/test_config_capability_tools.py @@ -307,10 +307,14 @@ def _guardrail_is_on() -> bool: def test_config_write_is_denied_without_an_approval_gate(cfg_home) -> None: - """Fail closed. ``requires_approval`` in the tool schema only drives capability - disclosure — it does not gate execution — so an unwired gate must block, not - silently allow. + """Fail closed and stop the agent turn rather than spending its loop budget. + + ``requires_approval`` only informs capability disclosure. A denied write must + also carry the engine's explicit hard-stop signal; otherwise the model retries + an action that can never obtain consent until the reasoning budget is exhausted. """ + from leapflow.security.permission_failures import is_permission_hard_stop_payload + config_tools.set_config_approval_gate(None) result = asyncio.run( @@ -319,6 +323,10 @@ def test_config_write_is_denied_without_an_approval_gate(cfg_home) -> None: assert result["ok"] is False assert result["requires_approval"] is True + assert result["failure_code"] == "approval_denied" + assert result["blocks_approval"] is True + assert result.get("failure_class") is None + assert is_permission_hard_stop_payload(result) is True assert _guardrail_is_on(), "a denied write must not reach disk" diff --git a/tests/test_dashboard_i18n_static.py b/tests/test_dashboard_i18n_static.py index 8b1380d5..55309266 100644 --- a/tests/test_dashboard_i18n_static.py +++ b/tests/test_dashboard_i18n_static.py @@ -120,10 +120,18 @@ def _translation_tables() -> dict[str, set[str]]: def _template_literals(node: object, found: set[str]) -> None: - """Collect every literal a renderer will display, skipping bound expressions.""" + """Collect every literal a renderer will display, skipping bound expressions. + + ``text`` is included because the Markdown renderer translates it like any other + text prop. It was omitted here for as long as the renderer did not translate it, + so seven Markdown notices stayed English in every locale while this check passed. + An interpolated string is skipped for the same reason as elsewhere: it can never + match a dictionary key, which is why a template must keep counts in ``Stat`` and + the prose in ``text`` literal. + """ if isinstance(node, dict): for key, value in node.items(): - if key in ("title", "subtitle", "label", "caption"): + if key in ("title", "subtitle", "label", "caption", "text"): if isinstance(value, str) and "{{" not in value: found.add(value) if key == "columns" and isinstance(value, list): diff --git a/tests/test_dashboard_view.py b/tests/test_dashboard_view.py index f4e94f91..365ce7b1 100644 --- a/tests/test_dashboard_view.py +++ b/tests/test_dashboard_view.py @@ -404,3 +404,151 @@ def test_only_actionable_admission_notes_reach_the_board() -> None: assert [n["outcome"] for n in notes] == ["demoted", "rejected"] assert _actionable_notes({}) == [] + + +# ── A domain armed with more than one watch ────────────────────────────────── + + +def _evolution_provider(*, live_has_findings: bool = False) -> _FakeProvider: + """Framework evolution as the daemon actually arms it: two watches, one domain. + + The polled watch carries the state snapshot; the event watch carries change and + has produced nothing until something evolves. Ordered with the empty one first, + which is the case that broke the page. + """ + watches = [ + { + "watch_id": "w-live", + "domain": "framework_evolution", + "name": "framework-evolution-live", + "state": "armed", + "run_count": 0, + }, + { + "watch_id": "w-poll", + "domain": "framework_evolution", + "name": "framework-evolution", + "state": "armed", + "run_count": 4, + }, + ] + findings = [ + { + "watch_id": "w-poll", + "domain": "framework_evolution", + "ts": 100.0, + "severity": "info", + "payload": {"summary": {"active_plugins": 17, "tool_count": 55}}, + } + ] + if live_has_findings: + findings.append({ + "watch_id": "w-live", + "domain": "framework_evolution", + "ts": 200.0, + "severity": "notable", + "payload": {"summary": {"active_plugins": 18, "tool_count": 58}}, + }) + return _FakeProvider(watches, findings) + + +def _build(provider: _FakeProvider, template: str = "evolution") -> dict: + import asyncio + + builder = DashboardViewBuilder(TemplateLibrary()) + return asyncio.run(builder.build(DashboardIntent(template=template), provider)) + + +def test_a_second_watch_on_a_domain_cannot_blank_the_board(): + """The data existed under a sibling watch, and the page rendered em dashes. + + Selecting the first watch matching the domain and scoping the finding read to it + meant an event-driven watch -- which has produced nothing until something + evolves -- could shadow the polled watch that holds the snapshot. + """ + spec = _build(_evolution_provider()) + + stats = { + (node.get("props") or {}).get("label"): (node.get("props") or {}).get("value") + for node in _flatten(spec) + if node.get("type") == "Stat" + } + # A whole-match ``{{ }}`` keeps the native type, so this is an int not a string. + assert stats.get("Plugins") == 17, f"board rendered without its payload: {stats}" + assert "empty" not in str(spec.get("data") or {}) + + +def test_the_newest_finding_wins_regardless_of_which_watch_produced_it(): + """Two watches with data: newest, not first-listed.""" + spec = _build(_evolution_provider(live_has_findings=True)) + + stats = { + (node.get("props") or {}).get("label"): (node.get("props") or {}).get("value") + for node in _flatten(spec) + if node.get("type") == "Stat" + } + assert stats.get("Plugins") == 18 + + +def test_an_unobserved_domain_explains_itself_instead_of_showing_dashes(): + """A board with no data must say why, and which of the three reasons it is.""" + provider = _FakeProvider( + [{"watch_id": "w-poll", "domain": "framework_evolution", "state": "armed", "run_count": 0}], + [], + ) + spec = _build(provider) + titles = [ + (node.get("props") or {}).get("title") + for node in _flatten(spec) + if node.get("type") == "Section" + ] + assert "Not yet observed" in titles + # A watch exists but has not completed a cycle: waiting, not unscheduled. + states = [ + (node.get("props") or {}).get("value") + for node in _flatten(spec) + if node.get("type") == "Stat" and (node.get("props") or {}).get("label") == "State" + ] + assert states == ["waiting"] + + +def test_no_watch_at_all_is_reported_as_unscheduled_not_waiting(): + """Nothing will ever arrive, which is a different next step from 'wait'.""" + spec = _build(_FakeProvider([], [])) + states = [ + (node.get("props") or {}).get("value") + for node in _flatten(spec) + if node.get("type") == "Stat" and (node.get("props") or {}).get("label") == "State" + ] + assert states == ["unscheduled"] + + +def test_a_ran_but_empty_domain_is_idle_rather_than_broken(): + """The producer ran and had nothing to say, which for this domain is legitimate.""" + provider = _FakeProvider( + [{"watch_id": "w-poll", "domain": "framework_evolution", "state": "armed", "run_count": 9}], + [], + ) + spec = _build(provider) + states = [ + (node.get("props") or {}).get("value") + for node in _flatten(spec) + if node.get("type") == "Stat" and (node.get("props") or {}).get("label") == "State" + ] + assert states == ["idle"] + + +def test_single_watch_domains_are_unaffected(): + """The resolver is shared with capability and hardware; one watch must behave as before.""" + provider = _FakeProvider( + [{"watch_id": "w-cap", "domain": "capability_adaptation", "state": "armed", "run_count": 2}], + [{ + "watch_id": "w-cap", + "domain": "capability_adaptation", + "ts": 10.0, + "severity": "info", + "payload": {"environment": {"fingerprint_id": "fp-1"}}, + }], + ) + spec = _build(provider, template="capability") + assert spec["root"], "capability board rendered nothing" diff --git a/tests/test_effect_declaration.py b/tests/test_effect_declaration.py new file mode 100644 index 00000000..63912db8 --- /dev/null +++ b/tests/test_effect_declaration.py @@ -0,0 +1,237 @@ +"""C-1: an acquisition can now be *confirmed*, not only refuted. + +Before this, the only outcome channel was the usage sink, which receives just ``ok``. +A successful call therefore graded ``unverifiable`` -- verification could refute an +acquisition but never confirm one, which made "verified by observed effect" half a +mechanism. + +C-1 closes it by recording outcomes where the **full result payload** is visible (the +engine's result-observation path) and by establishing the declaration channel: a +handler reports what it observably did under an ``effect`` key. Tools that say nothing +stay ``unverifiable`` -- silence must never be read as success. +""" + +from __future__ import annotations + +import asyncio + +from leapflow.domain.evolution_intent import EvolutionIntent +from leapflow.evolution.observations import ( + CoevolutionObservations, + install_observations, +) +from leapflow.evolution.sweep import CoevolutionSweep +from leapflow.learning.capability_effect_verifier import ( + OBSERVED_EFFECT_KEYS, + EFFECT_UNREPORTED, + VERIFIED, + observed_effect_from_result, +) + + +def _requirement(expected: str = "the reply was delivered to the thread"): + return EvolutionIntent.create( + "chat.reply", "send path no-ops", expected_effect=expected + ).to_requirement() + + +# ── the declaration channel ─────────────────────────────────────────────────── + + +def test_both_accepted_spellings_are_read(): + assert OBSERVED_EFFECT_KEYS == ("observed_effect", "effect") + assert observed_effect_from_result({"effect": "sent"}) == "sent" + assert observed_effect_from_result({"observed_effect": "sent"}) == "sent" + + +def test_observed_effect_wins_when_both_are_present(): + result = {"observed_effect": "explicit", "effect": "shorthand"} + assert observed_effect_from_result(result) == "explicit" + + +def test_silence_is_silence_and_is_never_synthesised(): + """An invented description could confirm an acquisition that never worked.""" + for result in ({}, {"ok": True}, {"ok": True, "effect": ""}, {"effect": " "}, + {"effect": 42}, None, "not-a-dict", []): + assert observed_effect_from_result(result) == "" + + +def test_whitespace_is_trimmed(): + assert observed_effect_from_result({"effect": " sent to thread \n"}) == "sent to thread" + + +# ── the engine records outcomes with the payload (drive the real method) ─────── + + +def _drive_engine_outcome(item): + from leapflow.engine.engine import AgentEngine + + AgentEngine._record_coevolution_outcome(item) + + +def _registry_with_tool(tool_name: str, plugin_id: str): + """Install a real registry whose arbitration maps tool -> plugin.""" + from leapflow.plugins import registry as registry_module + + class _Reg: + tool_owners = {tool_name: plugin_id} + + original = registry_module._REGISTRY if hasattr(registry_module, "_REGISTRY") else None + return _Reg(), original + + +def test_engine_confirms_an_acquisition_from_the_declared_effect(monkeypatch): + """The C-1 payoff: a successful call with a declared effect now verifies.""" + buf = CoevolutionObservations() + install_observations(buf) + reg, _ = _registry_with_tool("chat_reply", "gen_reply") + monkeypatch.setattr("leapflow.plugins.get_registry", lambda: reg) + try: + buf.record_acquisition("gen_reply") + buf.record_resolution(requirement=_requirement(), selected_plugin="gen_reply") + + _drive_engine_outcome({ + "name": "chat_reply", + "result": {"ok": True, "effect": "the reply was delivered to the thread"}, + }) + + outcome = asyncio.run(CoevolutionSweep().run( + verifications=buf.drain_verifications() + )) + assert outcome.verified == 1 + assert outcome.verdicts[0].reason == VERIFIED + finally: + install_observations(None) + + +def test_success_without_a_declared_effect_stays_unverifiable(monkeypatch): + """Silence must not be promoted to confirmation.""" + buf = CoevolutionObservations() + install_observations(buf) + reg, _ = _registry_with_tool("chat_reply", "gen_reply") + monkeypatch.setattr("leapflow.plugins.get_registry", lambda: reg) + try: + buf.record_acquisition("gen_reply") + buf.record_resolution(requirement=_requirement(), selected_plugin="gen_reply") + + _drive_engine_outcome({"name": "chat_reply", "result": {"ok": True}}) + + outcome = asyncio.run(CoevolutionSweep().run( + verifications=buf.drain_verifications() + )) + assert outcome.unverifiable == 1 + assert outcome.verdicts[0].reason == EFFECT_UNREPORTED + finally: + install_observations(None) + + +def test_engine_reads_error_payloads_as_failure(monkeypatch): + buf = CoevolutionObservations() + install_observations(buf) + reg, _ = _registry_with_tool("chat_reply", "gen_reply") + monkeypatch.setattr("leapflow.plugins.get_registry", lambda: reg) + try: + buf.record_acquisition("gen_reply") + buf.record_resolution(requirement=_requirement(), selected_plugin="gen_reply") + + _drive_engine_outcome({ + "name": "chat_reply", "result": {"ok": True, "error": "transport refused"}, + }) + + outcome = asyncio.run(CoevolutionSweep().run( + verifications=buf.drain_verifications() + )) + assert outcome.refuted == 1 + finally: + install_observations(None) + + +def test_wrong_effect_refutes_even_with_ok_true(monkeypatch): + """A structurally perfect adapter targeting the wrong thing must not pass.""" + buf = CoevolutionObservations() + install_observations(buf) + reg, _ = _registry_with_tool("chat_reply", "gen_reply") + monkeypatch.setattr("leapflow.plugins.get_registry", lambda: reg) + try: + buf.record_acquisition("gen_reply") + buf.record_resolution(requirement=_requirement(), selected_plugin="gen_reply") + + _drive_engine_outcome({ + "name": "chat_reply", + "result": {"ok": True, "effect": "opened a settings pane"}, + }) + + outcome = asyncio.run(CoevolutionSweep().run( + verifications=buf.drain_verifications() + )) + assert outcome.refuted == 1 + finally: + install_observations(None) + + +def test_unacquired_plugins_are_not_paired(monkeypatch): + """Ordinary traffic must leave the verification buffer untouched.""" + buf = CoevolutionObservations() + install_observations(buf) + reg, _ = _registry_with_tool("list_dir", "builtin_fs") + monkeypatch.setattr("leapflow.plugins.get_registry", lambda: reg) + try: + for _ in range(20): + _drive_engine_outcome({ + "name": "list_dir", "result": {"ok": True, "effect": "listed 3 files"}, + }) + assert buf.drain_verifications() == () + finally: + install_observations(None) + + +def test_malformed_items_and_unowned_tools_are_ignored(monkeypatch): + buf = CoevolutionObservations() + install_observations(buf) + reg, _ = _registry_with_tool("known", "p") + monkeypatch.setattr("leapflow.plugins.get_registry", lambda: reg) + try: + for item in (None, "str", [], {}, {"name": ""}, {"name": "orphan"}): + _drive_engine_outcome(item) # must not raise + assert buf.drain_verifications() == () + finally: + install_observations(None) + + +def test_recording_survives_a_broken_registry(monkeypatch): + """Observation must never affect execution.""" + def _boom(): + raise RuntimeError("registry down") + + monkeypatch.setattr("leapflow.plugins.get_registry", _boom) + _drive_engine_outcome({"name": "t", "result": {"ok": True}}) # must not raise + + +# ── the generator asks for it, or nothing will ever be confirmable ──────────── + + +def test_generation_prompt_requires_handlers_to_report_their_effect(): + """The contract has to reach the code that gets written, not just the verifier.""" + from leapflow.learning.plugin_generator import ( + PluginGenerationRequest, + PluginGenerator, + ) + + prompt = PluginGenerator().build_generation_prompt( + PluginGenerationRequest(plugin_id="gen_x", description="reply in a thread") + ) + assert '"effect"' in prompt + assert "can never be verified, only refuted" in prompt + # The skeleton must model it, since that is what gets copied. + assert '"effect": ""' in prompt + + +def test_usage_sink_no_longer_records_outcomes(): + """Recording in two places would double-count and grade successes unverifiable.""" + import inspect + + from leapflow.learning.plugin_stats import PluginUsageTracker + + source = inspect.getsource(PluginUsageTracker.record) + assert "record_tool_outcome" not in source + assert "tracker.record(plugin_id, tool_name, ok)" in source # streak feed stays diff --git a/tests/test_evolution_governance_reachable.py b/tests/test_evolution_governance_reachable.py new file mode 100644 index 00000000..6150fde1 --- /dev/null +++ b/tests/test_evolution_governance_reachable.py @@ -0,0 +1,283 @@ +"""P2(a): the evolution governance tier becomes reachable. + +`AdaptiveEvolutionPolicy` and `LifecycleGovernor` implement trust, probation and +quarantine. Everything they need was built and path-declared -- and orphaned: +`JsonCapabilityProposalQueue`, `JsonPluginOutcomeStore`, and both +`ProfileLayout` paths had no references outside their own modules, so nothing in +production could ever reach the machinery. + +These tests cover the two halves of the fix: + +* **Protocols** -- the policy and governor now state what they require + (`EvolutionProposalView`, `EvolutionLifecycleStore`, `OutcomeStore`) instead of + binding to one concrete store, so they can be driven by the live chain. +* **A production filler** -- `plugin_propose` now opens a correlated lifecycle + record, giving the governor something real to govern. + +Also guards the distinction that must not be collapsed: the review vocabulary +(`draft | review | approved | rejected`) and the acquisition-lifecycle vocabulary +(`PENDING ... QUARANTINED`) are different concerns in different stores. +""" + +from __future__ import annotations + +import asyncio + +from leapflow.domain.capability_requirement import CapabilityRequirement +from leapflow.learning.plugin_trust import PluginTrustLedger, PluginTrustLevel +from leapflow.plugins.adaptive_policy import AdaptiveEvolutionPolicy +from leapflow.plugins.evolution_contracts import ( + EvolutionLifecycleStore, + EvolutionProposalView, + OutcomeStore, +) +from leapflow.plugins.lifecycle_governor import LifecycleGovernor +from leapflow.storage.capability_proposal_queue import ( + CapabilityProposalItem, + JsonCapabilityProposalQueue, +) +from leapflow.storage.plugin_outcome_store import JsonPluginOutcomeStore + + +def _queue(tmp_path) -> JsonCapabilityProposalQueue: + return JsonCapabilityProposalQueue(tmp_path / "lifecycle.json") + + +def _requirement() -> CapabilityRequirement: + return CapabilityRequirement.create( + "chat.reply", "explicit_request", max_risk_level="read_only", requirement_id="r1" + ) + + +# ── the contracts are satisfied by the shipped stores ───────────────────────── + + +def test_shipped_types_satisfy_the_new_protocols(tmp_path): + item = CapabilityProposalItem(proposal_id="p1", status="PENDING", requirements=()) + assert isinstance(item, EvolutionProposalView) + assert isinstance(_queue(tmp_path), EvolutionLifecycleStore) + assert isinstance(JsonPluginOutcomeStore(tmp_path / "o.json"), OutcomeStore) + + +def test_policy_accepts_any_conforming_view(): + """The policy no longer requires the concrete queue item.""" + + class _View: + proposal_id = "p9" + status = "PENDING" + requirements: tuple = () + risk = {"risk_level": "read_only"} + + view = _View() + assert isinstance(view, EvolutionProposalView) + decision = AdaptiveEvolutionPolicy(autonomy_level="approve_to_install").decide(view) + assert decision.action == "generate" + + +def test_governor_accepts_any_conforming_stores(tmp_path): + """The governor can be driven by a non-default backing.""" + transitions: list[tuple] = [] + + class _Store: + def get(self, proposal_id): + return None + + def update(self, proposal_id, **kwargs): + transitions.append((proposal_id, kwargs.get("status"))) + return None + + class _Outcomes: + def __init__(self): + self.streak = 0 + + def add_outcome(self, **kwargs): + self.streak = 0 if kwargs.get("ok") else self.streak + 1 + return dict(kwargs) + + def failure_streak(self, plugin_id): + return self.streak + + store, outcomes = _Store(), _Outcomes() + assert isinstance(store, EvolutionLifecycleStore) + assert isinstance(outcomes, OutcomeStore) + governor = LifecycleGovernor(proposal_queue=store, outcome_store=outcomes) + result = asyncio.run(governor.record_outcome( + proposal_id="p1", plugin_id="pl1", tool_name="t1", ok=True + )) + assert result.action == "probation_execute" + assert transitions == [("p1", "PROBATION")] + + +# ── the two vocabularies stay separate ──────────────────────────────────────── + + +def test_review_and_lifecycle_vocabularies_are_distinct(): + """Collapsing these into one field would lose a whole dimension of state.""" + from leapflow.domain.plugin_proposal import ProposalStatus as ReviewStatus + from leapflow.storage.capability_proposal_queue import ProposalStatus as LifecycleStatus + from typing import get_args + + review = set(get_args(ReviewStatus)) + lifecycle = set(get_args(LifecycleStatus)) + assert review == {"draft", "review", "approved", "rejected"} + assert {"PENDING", "INSTALLED", "PROBATION", "QUARANTINED"} <= lifecycle + # They intentionally share no member: different concerns, different stores. + assert review & lifecycle == set() + + +# ── the governance tier now runs end to end on the shipped stores ───────────── + + +def test_full_governance_cycle_on_the_shipped_stores(tmp_path): + """PENDING -> generate decision -> outcomes -> quarantine, all real components.""" + queue = _queue(tmp_path) + outcomes = JsonPluginOutcomeStore(tmp_path / "outcomes.json") + trust = PluginTrustLedger() + item = queue.enqueue( + requirements=[_requirement()], + risk={"risk_level": "read_only"}, + source="plugin_propose", + metadata={"plugin_id": "chat_reply_plugin"}, + ) + assert item.status == "PENDING" + + # The policy can now decide on a record that a production caller created. + decision = AdaptiveEvolutionPolicy(autonomy_level="approve_to_install").decide( + item, trust_level=PluginTrustLevel.DRAFT + ) + assert decision.action == "generate" + + # The governor transitions that same record from execution outcomes. + disabled: list[str] = [] + + class _Actor: + async def disable(self, *, plugin_id): + disabled.append(plugin_id) + return {"ok": True} + + governor = LifecycleGovernor( + proposal_queue=queue, outcome_store=outcomes, + lifecycle_actor=_Actor(), trust_ledger=trust, quarantine_after=3, + ) + for _ in range(2): + result = asyncio.run(governor.record_outcome( + proposal_id=item.proposal_id, plugin_id="chat_reply_plugin", + tool_name="chat_reply", ok=False, + )) + assert result.action == "probation_execute" + final = asyncio.run(governor.record_outcome( + proposal_id=item.proposal_id, plugin_id="chat_reply_plugin", + tool_name="chat_reply", ok=False, + )) + assert final.action == "quarantine" + assert final.failure_streak == 3 + assert disabled == ["chat_reply_plugin"] + assert queue.get(item.proposal_id).status == "QUARANTINED" + + +def test_requarantined_record_is_reset_in_place_but_memory_survives_elsewhere(tmp_path): + """Re-proposing after quarantine resets the record; the safety memory does not live there. + + ``proposal_id`` is a content hash of the requirement payload, so re-proposing + the same capability reuses the id and resets it to ``PENDING`` -- the + quarantine history is *overwritten* at the proposal layer rather than a second + record being created. That is safe only because the trigger the governor + actually consults lives elsewhere: the outcome store's ``failure_streak`` and + the trust ledger's freeze both persist independently, so a re-proposed plugin + does not get a clean slate where it matters. + """ + queue = _queue(tmp_path) + first = queue.enqueue(requirements=[_requirement()], source="plugin_propose") + queue.update(first.proposal_id, status="QUARANTINED") + second = queue.enqueue(requirements=[_requirement()], source="plugin_propose") + + assert second.proposal_id == first.proposal_id # content-addressed + assert second.status == "PENDING" # reset, ready to retry + assert len(queue.list_items(limit=0)) == 1 # replaced, not appended + + # The memory that gates a retry survives the reset. + outcomes = JsonPluginOutcomeStore(tmp_path / "outcomes.json") + for _ in range(3): + outcomes.add_outcome(plugin_id="p", tool_name="t", ok=False) + assert outcomes.failure_streak("p") == 3 + + trust = PluginTrustLedger() + trust.record_failure("p", hard=True) + assert PluginTrustLedger.load_state(trust.to_state()).is_frozen("p") is True + + +# ── plugin_propose now opens a lifecycle record (the production filler) ─────── + + +def _plugin_with_stores(tmp_path): + from leapflow.plugins.tool_plugins.self_management import SelfManagementPlugin + from leapflow.storage.plugin_proposal_store import JsonPluginProposalStore + + plugin = SelfManagementPlugin() + queue = _queue(tmp_path) + plugin.bind_runtime( + plugin_proposal_store=JsonPluginProposalStore(tmp_path / "review.json"), + capability_lifecycle_store=queue, + ) + return plugin, queue + + +def _propose(plugin, **kwargs): + handler = {t.name: t.handler for t in plugin.tools}["plugin_propose"] + return asyncio.run(handler(**kwargs)) + + +def test_plugin_propose_opens_a_correlated_lifecycle_record(tmp_path): + plugin, queue = _plugin_with_stores(tmp_path) + result = _propose( + plugin, + requested_capability="chat.reply", + plugin_id="chat_reply_plugin", + risk_level="read_only", + ) + assert result["ok"] is True + lifecycle_id = result["lifecycle_proposal_id"] + assert lifecycle_id + + record = queue.get(lifecycle_id) + assert record is not None + assert record.status == "PENDING" # ready for the policy + meta = dict(record.metadata) + assert meta["plugin_id"] == result["proposal"]["plugin_id"] + assert meta["review_proposal_id"] == result["proposal"]["proposal_id"] + + # The review proposal keeps its own, separate vocabulary. + assert result["proposal"]["status"] == "draft" + + # ...and the policy can act on the record the tool just created. + decision = AdaptiveEvolutionPolicy(autonomy_level="approve_to_install").decide(record) + assert decision.action == "generate" + + +def test_propose_still_succeeds_when_the_lifecycle_ledger_fails(tmp_path): + """Bookkeeping must never fail the proposal the caller asked for.""" + from leapflow.plugins.tool_plugins.self_management import SelfManagementPlugin + from leapflow.storage.plugin_proposal_store import JsonPluginProposalStore + + class _Broken: + def enqueue(self, **kwargs): + raise OSError("disk on fire") + + plugin = SelfManagementPlugin() + plugin.bind_runtime( + plugin_proposal_store=JsonPluginProposalStore(tmp_path / "review.json"), + capability_lifecycle_store=_Broken(), + ) + result = _propose(plugin, requested_capability="chat.reply", risk_level="read_only") + assert result["ok"] is True # the proposal survived + assert result["lifecycle_proposal_id"] == "" # ...and the failure is visible + + +def test_lifecycle_record_carries_the_declared_risk_ceiling(tmp_path): + plugin, queue = _plugin_with_stores(tmp_path) + result = _propose( + plugin, requested_capability="chat.send", risk_level="medium", + ) + record = queue.get(result["lifecycle_proposal_id"]) + assert dict(record.risk)["risk_level"] == "medium" + assert dict(record.requirements[0])["max_risk_level"] == "medium" diff --git a/tests/test_evolution_ledger.py b/tests/test_evolution_ledger.py new file mode 100644 index 00000000..769da8df --- /dev/null +++ b/tests/test_evolution_ledger.py @@ -0,0 +1,509 @@ +"""EvolutionLedger: rebuilding causal episodes from records that already exist. + +The point of this stage is that no probe is needed, so the tests are mostly about +the two ends the decision record never joined: the environment evidence that +preceded it, and whether the gap it was meant to close actually closed. + +The negative cases carry the most weight: + +* a retired observation must be reported as ``declared_fitness``, never as + verified -- the engine retires on re-resolution, and the recorded v0.7 defect was + a wrongly selected tool retiring the evidence for its own gap; +* a recurrence must surface as ``reopened``, which is the outcome the system could + not express at all before the observation lifecycle was closed; +* the two proposal vocabularies must not be conflated; +* one malformed record must not blank the timeline. +""" + +from __future__ import annotations + +from leapflow.domain.evolution_trace import ( + ABORTED, + COMMITTED, + DECLARED_FITNESS, + NOT_APPLICABLE, + OPEN, + REOPENED, + RESOLVED, + STILL_OPEN, + EvolutionStage, +) +from leapflow.evolution import EvolutionLedger + + +class _Plans: + """Mirrors ``JsonCapabilityPlanStore.list_records``: non-mappings filtered, newest first.""" + + def __init__(self, records: list) -> None: + self._records = records + + def list_records(self, *, limit: int = 20): + usable = [dict(r) for r in self._records if isinstance(r, dict)] + ordered = sorted(usable, key=lambda r: float(r.get("created_at") or 0.0), reverse=True) + return ordered if limit <= 0 else ordered[:limit] + + +class _Observations: + def __init__(self, records: list[dict]) -> None: + self._records = records + + def list_observations(self, *, limit: int = 50): + return list(self._records) if limit <= 0 else list(self._records)[:limit] + + +class _Level: + def __init__(self, name: str) -> None: + self.name = name + + +class _Trust: + def __init__(self, levels: dict[str, str]) -> None: + self._levels = levels + + def level(self, plugin_id: str) -> _Level: + return _Level(self._levels.get(plugin_id, "DRAFT")) + + +def _record(**over) -> dict: + base = { + "record_id": "r1", + "created_at": 1000.0, + "source": "runtime", + "requirements": [ + { + "requirement_id": "req-unknown-tool-list_dir", + "capability": "list_dir", + "origin": "unknown_tool", + "evidence": "Runtime attempted unknown tool 'list_dir'.", + "metadata": {}, + } + ], + "resolutions": [], + "plan": {"executable": True}, + "observation_ids": ["obs-1"], + } + base.update(over) + return base + + +def _observation(**over) -> dict: + base = { + "observation_id": "obs-1", + "first_seen_at": 900.0, + "last_seen_at": 950.0, + "occurrence_count": 3, + "result": {"error_type": "unknown_tool", "original_tool_name": "list_dir"}, + } + base.update(over) + return base + + +def _ledger(records, observations=None, trust=None, **kw) -> EvolutionLedger: + return EvolutionLedger( + plan_store=_Plans(records), + observation_store=_Observations(observations or []), + trust_ledger=trust, + **kw, + ) + + +# ── the two ends the decision record never joined ──────────────────────────── + + +def test_episode_joins_environment_cause_to_framework_change(): + """One record plus its observations is a complete five-stage story.""" + record = _record( + mutation={"action": "install", "plugin_id": "list_dir_plugin"}, + registry_version_before=7, + registry_version_after=8, + policy_decision={"action": "install", "autonomy_level": "trusted", "reason": "ok"}, + ) + episodes = _ledger([record], [_observation()], _Trust({"list_dir_plugin": "DRAFT"})).recent_episodes() + + assert len(episodes) == 1 + episode = episodes[0] + assert episode.episode_id == "ep-r1" + assert episode.driver == "unknown_tool" + assert episode.capability == "list_dir" + assert episode.mutation_action == "install" + assert episode.plugin_id == "list_dir_plugin" + assert episode.registry_before == 7 and episode.registry_after == 8 + assert episode.framework_changed is True + assert episode.status == COMMITTED + assert episode.policy_action == "install" + assert episode.trust_now == "DRAFT" + + stages = episode.stages_present + assert EvolutionStage.OBSERVE in stages + assert EvolutionStage.ORIENT in stages + assert EvolutionStage.DECIDE in stages + assert EvolutionStage.ACT in stages + + +def test_world_model_hypothesis_travels_into_the_episode(): + """The teacher's own words reach the timeline without any probe. + + They ride on the requirement metadata, which is the only place they become + durable; the driver's own counts are never persisted. + """ + record = _record( + requirements=[ + { + "requirement_id": "req-wm-wmi-abc", + "capability": "ui.chat.send", + "origin": "world_model", + "evidence": "the agent had no way to send a chat message", + "metadata": { + "intent_id": "wmi-abc", + "confidence": 0.8, + "expected_effect": "message appears in the thread", + }, + } + ], + observation_ids=["obs-wm"], + ) + observation = _observation( + observation_id="obs-wm", + result={"error_type": "world_model_intent", "capability": "ui.chat.send"}, + ) + episode = _ledger([record], [observation]).recent_episodes()[0] + + assert episode.driver == "world_model" + assert episode.intent_id == "wmi-abc" + assert episode.confidence == 0.8 + assert "chat message" in episode.hypothesis + + +def test_confidence_is_clamped_and_never_raises(): + record = _record( + requirements=[ + { + "capability": "x", + "origin": "world_model", + "metadata": {"intent_id": "wmi-1", "confidence": "not-a-number"}, + } + ] + ) + assert _ledger([record]).recent_episodes()[0].confidence == 0.0 + + +# ── gap closure: the consequence ───────────────────────────────────────────── + + +def test_retired_observation_is_declared_fitness_never_verified(): + """The engine retires on re-resolution, which does not prove the tool works. + + Reporting this as verified would reproduce the recorded v0.7 defect in the + reader's head: a wrongly selected tool retired the evidence for its own gap. + """ + record = _record( + mutation={"action": "install", "plugin_id": "p"}, + registry_version_before=1, + registry_version_after=2, + ) + observation = _observation(status="resolved", status_reason="list_dir resolved") + episode = _ledger([record], [observation]).recent_episodes()[0] + + assert episode.gap_closure == RESOLVED + assert episode.verification_tier == DECLARED_FITNESS + # Effect verification is not wired, so there is no verdict to report. + assert episode.effect_verdict == "" + assert "declared fitness" in episode.outcome + + +def test_recurrence_surfaces_as_a_regression(): + """The outcome the system could not express before the lifecycle was closed.""" + record = _record( + mutation={"action": "install", "plugin_id": "p"}, + registry_version_before=1, + registry_version_after=2, + ) + observation = _observation( + status="open", status_reason="reopened after recurrence at 1200.0" + ) + episode = _ledger([record], [observation]).recent_episodes()[0] + + assert episode.gap_closure == REOPENED + assert episode.outcome == "regressed" + learn = [t for t in episode.traces if t.stage is EvolutionStage.LEARN] + assert any(t.kind == "observation_reopened" for t in learn) + + +def test_framework_changed_but_gap_still_open_is_distinguished(): + """Installed something and the gap did not close: not a success, not a failure.""" + record = _record( + mutation={"action": "install", "plugin_id": "p"}, + registry_version_before=1, + registry_version_after=2, + ) + episode = _ledger([record], [_observation()]).recent_episodes()[0] + + assert episode.gap_closure == STILL_OPEN + assert episode.verification_tier == "" + assert "still open" in episode.outcome + + +def test_absent_status_field_counts_as_open(): + """A freshly written observation carries no ``status`` at all. + + The store's own ``unresolved()`` treats that absence as open, so the ledger + must too -- otherwise a brand-new gap would read as closed. + """ + observation = _observation() + assert "status" not in observation + record = _record(mutation={"action": "install"}, registry_version_before=1, registry_version_after=2) + assert _ledger([record], [observation]).recent_episodes()[0].gap_closure == STILL_OPEN + + +def test_no_linked_observation_is_not_applicable_not_a_failure(): + episode = _ledger([_record(observation_ids=[])]).recent_episodes()[0] + assert episode.gap_closure == NOT_APPLICABLE + + +# ── episode status ─────────────────────────────────────────────────────────── + + +def test_deciding_not_to_change_still_closes_the_episode(): + """Why the framework did *not* evolve is part of transparency, not an open loop.""" + record = _record(policy_decision={"action": "observe_only", "reason": "risk too high"}) + episode = _ledger([record]).recent_episodes()[0] + + assert episode.status == COMMITTED + assert episode.outcome == "no action (observe_only)" + + +def test_attempted_mutation_that_did_not_move_the_registry_stays_open(): + """An attempt with no effect must not read as a clean conclusion.""" + record = _record( + mutation={"action": "install", "plugin_id": "p"}, + registry_version_before=5, + registry_version_after=5, + ) + episode = _ledger([record]).recent_episodes()[0] + + assert episode.framework_changed is False + assert episode.status == OPEN + assert "registry unchanged" in episode.outcome + + +def test_stale_unclosed_episode_is_aborted(): + record = _record(created_at=100.0) + episodes = _ledger([record], episode_ttl_s=60.0).recent_episodes(now=1000.0) + assert episodes[0].status == ABORTED + + +def test_ttl_is_not_applied_without_a_clock(): + """``now=0`` means "no clock supplied", not "the epoch".""" + record = _record(created_at=100.0) + assert _ledger([record], episode_ttl_s=60.0).recent_episodes()[0].status == OPEN + + +# ── vocabularies and decision transparency ─────────────────────────────────── + + +def test_the_two_proposal_vocabularies_are_not_conflated(): + """A decision record's ``proposal.status`` is the acquisition lifecycle. + + The review vocabulary lives in a different store and answers a different + question, so borrowing this value for it would merge two things the code + upstream explicitly warns must stay apart. + """ + record = _record(proposal={"proposal_id": "cp-1", "status": "PROBATION"}) + episode = _ledger([record]).recent_episodes()[0] + + assert episode.lifecycle_status == "PROBATION" + assert episode.review_status == "" + + +def test_losing_candidates_are_preserved_in_the_decide_trace(): + """Why a candidate lost is half of decision transparency.""" + record = _record( + resolutions=[ + { + "requirement": {"capability": "list_dir"}, + "selected": {"candidate": {"tool_name": "file_list"}}, + "candidates": [ + { + "candidate": {"tool_name": "other"}, + "eligible": False, + "exclusion_reasons": ["risk 'high' exceeds max 'read_only'"], + } + ], + } + ], + policy_decision={"action": "install", "reason": "chosen"}, + ) + episode = _ledger([record]).recent_episodes()[0] + decide = episode.trace_of(EvolutionStage.DECIDE) + + assert decide is not None + assert decide.detail["resolutions"][0]["candidates"][0]["exclusion_reasons"] + + +def test_trust_at_decision_is_read_from_the_record_and_now_from_the_ledger(): + """Two different facts, so two different fields. + + There is no stored history to reconstruct a before/after pair from; presenting + a live reading as an "after" would imply a comparison never made. + """ + record = _record( + mutation={"action": "install", "plugin_id": "p"}, + proposal={"status": "INSTALLED", "trust_state": {"trust_level": "DRAFT"}}, + ) + episode = _ledger([record], trust=_Trust({"p": "CANDIDATE"})).recent_episodes()[0] + + assert episode.trust_at_decision == "DRAFT" + assert episode.trust_now == "CANDIDATE" + + +# ── robustness ─────────────────────────────────────────────────────────────── + + +def test_one_malformed_record_does_not_blank_the_timeline(): + """A record the builder cannot read must cost only itself. + + ``requirements`` as a string gets past the store (it filters non-mappings at + the top level only) and breaks the per-record assembly, which is exactly the + case the inner guard exists for. + """ + good = _record(record_id="good", created_at=2000.0) + broken = _record(record_id="broken", created_at=2001.0, requirements="not-a-list-of-mappings") + episodes = _ledger([good, broken]).recent_episodes() + + ids = [episode.episode_id for episode in episodes] + assert "ep-good" in ids + + +def test_unreadable_plan_store_returns_empty_rather_than_raising(): + class _Broken: + def list_records(self, **_kw): + raise OSError("disk gone") + + ledger = EvolutionLedger(plan_store=_Broken()) + assert ledger.recent_episodes() == () + + +def test_unreadable_observation_store_degrades_to_no_cause(): + class _Broken: + def list_observations(self, **_kw): + raise OSError("disk gone") + + ledger = EvolutionLedger(plan_store=_Plans([_record()]), observation_store=_Broken()) + episode = ledger.recent_episodes()[0] + # The decision is still there; only the cause is missing. + assert episode.capability == "list_dir" + assert episode.gap_closure == NOT_APPLICABLE + + +def test_absent_observation_store_is_tolerated(): + ledger = EvolutionLedger(plan_store=_Plans([_record()])) + assert ledger.recent_episodes()[0].capability == "list_dir" + + +def test_trust_read_failure_does_not_break_the_episode(): + class _Broken: + def level(self, _plugin_id): + raise RuntimeError("ledger closed") + + record = _record(mutation={"action": "install", "plugin_id": "p"}) + episode = _ledger([record], trust=_Broken()).recent_episodes()[0] + assert episode.trust_now == "" + + +def test_records_are_newest_first_and_bounded(): + records = [_record(record_id=f"r{i}", created_at=float(i)) for i in range(10)] + episodes = _ledger(records).recent_episodes(limit=3) + assert [e.episode_id for e in episodes] == ["ep-r9", "ep-r8", "ep-r7"] + + +def test_traces_are_ordered_by_time(): + record = _record( + created_at=1000.0, + mutation={"action": "install", "plugin_id": "p"}, + policy_decision={"action": "install"}, + ) + episode = _ledger([record], [_observation(first_seen_at=500.0)]).recent_episodes()[0] + timestamps = [trace.ts for trace in episode.traces] + assert timestamps == sorted(timestamps) + # The cause precedes the decision. + assert episode.traces[0].stage is EvolutionStage.OBSERVE + + +# ── against the real stores ────────────────────────────────────────── + + +def test_full_lifecycle_against_the_real_stores(tmp_path): + """Drive the three interesting endings through the real store implementations. + + The fakes above encode this module's assumptions about field names and status + defaults; only the real stores can confirm them. This walks one capability from + acquired-but-unhelpful, to closed, to *recurred* -- the last being the outcome + the system could not express at all before the observation lifecycle was closed. + """ + from leapflow.storage.capability_observation_store import JsonCapabilityObservationStore + from leapflow.storage.capability_plan_store import JsonCapabilityPlanStore + + observations = JsonCapabilityObservationStore(tmp_path / "obs.json") + plans = JsonCapabilityPlanStore(tmp_path / "plans.json") + evidence = { + "error_type": "world_model_intent", + "capability": "ui.chat.send", + "evidence": "the agent had no way to send a chat message", + } + + stored = observations.add_observation(result=evidence, source="world_model") + # A freshly written observation carries no status at all; the ledger must read + # that absence as open, exactly as the store's own unresolved() does. + assert "status" not in stored + + plans.add_record( + requirements=[ + { + "requirement_id": "req-wm-wmi-1", + "capability": "ui.chat.send", + "origin": "world_model", + "evidence": evidence["evidence"], + "metadata": {"intent_id": "wmi-1", "confidence": 0.9}, + } + ], + mutation={"action": "install", "plugin_id": "chat_plugin"}, + registry_version_before=10, + registry_version_after=11, + policy_decision={"action": "install", "autonomy_level": "trusted"}, + proposal={"status": "INSTALLED", "trust_state": {"trust_level": "DRAFT"}}, + observation_ids=[stored["observation_id"]], + ) + + def episode(): + return EvolutionLedger( + plan_store=plans, observation_store=observations + ).recent_episodes()[0] + + # 1. Acquired, and the gap it was meant to close is still open. + first = episode() + assert first.driver == "world_model" + assert first.intent_id == "wmi-1" + assert first.confidence == 0.9 + assert first.framework_changed is True + assert first.lifecycle_status == "INSTALLED" + assert first.review_status == "" + assert first.gap_closure == STILL_OPEN + assert first.verification_tier == "" + + # 2. Retired -- but only ever on declared fitness. + observations.mark_status(stored["observation_id"], "resolved", reason="resolved") + closed = episode() + assert closed.gap_closure == RESOLVED + assert closed.verification_tier == DECLARED_FITNESS + + # 3. The same evidence recurs: the store reopens the record, and the episode + # must report a regression rather than keeping its clean conclusion. + observations.add_observation(result=evidence, source="world_model") + regressed = episode() + assert regressed.gap_closure == REOPENED + assert regressed.outcome == "regressed" + assert any( + trace.kind == "observation_reopened" + for trace in regressed.traces + if trace.stage is EvolutionStage.LEARN + ) diff --git a/tests/test_evolution_producer.py b/tests/test_evolution_producer.py new file mode 100644 index 00000000..ba13b548 --- /dev/null +++ b/tests/test_evolution_producer.py @@ -0,0 +1,878 @@ +"""EvolutionProducer: the framework-evolution transparency panel. + +The tests that matter most here are the negative ones. This producer reports +LeapFlow's own composition, so the failure modes are all about claiming more than +was measured: + +* it must never report a pipeline segment as working because a module exists; +* it must distinguish "nothing observed" from "could not read"; +* it must not fail the monitor cycle when the registry is unreachable; +* its dedup key must be a content fingerprint, or an unchanged framework either + re-notifies every cycle or (if the clock leaks in) never dedups at all. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from leapflow.monitor.evolution_producer import ( + NO_EVIDENCE, + NOT_ADMITTED, + UNVERIFIABLE, + WIRED, + EvolutionProducer, +) +from leapflow.monitor.types import Severity, WatchSpec + + +def _ctx(now: float = 1000.0) -> SimpleNamespace: + return SimpleNamespace( + spec=WatchSpec(name="framework-evolution", domain="framework_evolution"), + now=now, + run_count=0, + last_run_at=0.0, + services=None, + force=False, + ) + + +def _observe(producer: EvolutionProducer | None = None, **kwargs): + return asyncio.run((producer or EvolutionProducer()).observe(_ctx(**kwargs))) + + +# ── fakes ───────────────────────────────────────────────────────────────────── + + +class _Tool: + def __init__(self, name: str, provides: tuple[str, ...] = ()) -> None: + self.name = name + self.provides_capabilities = provides + + +class _Plugin: + def __init__(self, plugin_id: str, tools: list[_Tool]) -> None: + self.plugin_id = plugin_id + self.tools = tools + + +class _Conflict: + def __init__(self, tool_name: str, kept: str, rejected: str) -> None: + self.tool_name = tool_name + self.kept_plugin = kept + self.rejected_plugin = rejected + + +class _Registry: + def __init__(self, plugins: dict[str, _Plugin], *, version: int = 7, conflicts=()) -> None: + self.plugins = plugins + self.version = version + self.conflicts = list(conflicts) + self.tool_owners = { + tool.name: pid for pid, plugin in plugins.items() for tool in plugin.tools + } + self.tool_handlers = {name: object() for name in self.tool_owners} + + +class _Level: + def __init__(self, name: str) -> None: + self.name = name + + +class _Trust: + def __init__(self, levels: dict[str, str], frozen: set[str] | None = None) -> None: + self._levels = levels + self._frozen = frozen or set() + + def level(self, plugin_id: str) -> _Level: + return _Level(self._levels.get(plugin_id, "DRAFT")) + + def is_frozen(self, plugin_id: str) -> bool: + return plugin_id in self._frozen + + +class _Stats: + def __init__(self, total_calls: int) -> None: + self.total_calls = total_calls + + +class _Usage: + def __init__(self, calls: dict[str, int]) -> None: + self._calls = calls + + def stats_for_plugin(self, plugin_id: str): + if plugin_id not in self._calls: + return None + return _Stats(self._calls[plugin_id]) + + +def _install_registry(monkeypatch, registry, *, trust=None, usage=None, fibers=None): + """Point the producer's lazy lookups at fakes. + + Patched on the producer module rather than the source packages because the + producer imports them inside the call, which is what keeps ``leapflow.monitor`` + importable without the plugin subsystem. + """ + import leapflow.plugins as plugins_pkg + from leapflow.monitor import evolution_producer as mod + + monkeypatch.setattr(plugins_pkg, "get_registry", lambda: registry, raising=False) + monkeypatch.setattr( + mod.EvolutionProducer, "_trust_and_usage", staticmethod(lambda: (trust, usage)) + ) + monkeypatch.setattr( + mod.EvolutionProducer, "_fiber_states", staticmethod(lambda: dict(fibers or {})) + ) + # Stores are profile-scoped; a unit test has no profile layout. + monkeypatch.setattr( + mod.EvolutionProducer, "_json_store", staticmethod(lambda *a, **k: None) + ) + + +def _rows(finding) -> dict[str, dict]: + return {row["key"]: row for row in finding.payload["reachability"]} + + +# ── the snapshot must be a live read, and must degrade honestly ─────────────── + + +def test_registry_unreachable_reports_unverified_not_empty(monkeypatch): + """A registry that cannot be read is not the same as a framework with no plugins.""" + import leapflow.plugins as plugins_pkg + from leapflow.monitor import evolution_producer as mod + + def _boom(): + raise RuntimeError("no registry in this process") + + monkeypatch.setattr(plugins_pkg, "get_registry", _boom, raising=False) + monkeypatch.setattr(mod.EvolutionProducer, "_json_store", staticmethod(lambda *a, **k: None)) + + findings = _observe() + assert len(findings) == 1 + payload = findings[0].payload + assert payload["summary"]["registry_readable"] is False + assert payload["roster"] == [] + assert "could not be verified" in payload["summary"]["headline"] + # Unverifiable runtime state is worth surfacing, not filing silently. + assert findings[0].severity is Severity.NOTABLE + + +def test_roster_and_topology_come_from_the_live_registry(monkeypatch): + registry = _Registry( + { + "alpha": _Plugin("alpha", [_Tool("a_read", ("read.file",)), _Tool("a_write", ())]), + "beta": _Plugin("beta", [_Tool("b_ping", ("net.ping",))]), + }, + version=42, + ) + _install_registry( + monkeypatch, + registry, + trust=_Trust({"alpha": "VERIFIED", "beta": "DRAFT"}), + fibers={"alpha": "active", "beta": "loading"}, + ) + + payload = _observe()[0].payload + roster = {row["plugin_id"]: row for row in payload["roster"]} + assert roster["alpha"]["trust_level"] == "VERIFIED" + assert roster["alpha"]["trust_class"] == "accruing" + assert roster["alpha"]["fiber_state"] == "active" + assert roster["alpha"]["tool_count"] == 2 + assert roster["beta"]["fiber_state"] == "loading" + assert payload["summary"]["registry_version"] == 42 + assert payload["summary"]["tool_count"] == 3 + + node_ids = {node["id"] for node in payload["topology"]["nodes"]} + assert {"plugin:alpha", "tool:a_read", "capability:read.file"} <= node_ids + assert {"source": "plugin:alpha", "target": "tool:a_read", "kind": "owns"} in payload[ + "topology" + ]["edges"] + + +def test_tools_owned_by_another_plugin_are_excluded(monkeypatch): + """The roster must match what the model can actually call, not what a plugin declares. + + Tool names are one global namespace arbitrated first-wins, so a losing + challenger still *declares* the tool while ``tool_owners`` says otherwise. + """ + registry = _Registry({"winner": _Plugin("winner", [_Tool("shared")])}) + registry.plugins["loser"] = _Plugin("loser", [_Tool("shared")]) + # tool_owners still credits the incumbent. + _install_registry(monkeypatch, registry) + + roster = {row["plugin_id"]: row for row in _observe()[0].payload["roster"]} + assert roster["winner"]["tool_count"] == 1 + assert roster["loser"]["tool_count"] == 0 + + +def test_frozen_plugin_is_distinguished_from_draft(monkeypatch): + """``DRAFT`` cannot say whether a plugin is new or permanently disqualified.""" + registry = _Registry({"new": _Plugin("new", []), "bad": _Plugin("bad", [])}) + _install_registry( + monkeypatch, + registry, + trust=_Trust({"new": "DRAFT", "bad": "DRAFT"}, frozen={"bad"}), + ) + + finding = _observe()[0] + roster = {row["plugin_id"]: row for row in finding.payload["roster"]} + assert roster["new"]["trust_class"] == "new_unproven" + assert roster["new"]["selectable"] == "yes" + assert roster["bad"]["trust_class"] == "frozen" + # The frozen-yet-selectable window is the whole point of the column. + assert roster["bad"]["selectable"] == "no" + assert finding.payload["summary"]["frozen_count"] == 1 + assert finding.severity is Severity.NOTABLE + + +def test_absent_trust_ledger_reports_unverified_never_a_guess(monkeypatch): + """In-process runs bind no advisor; the roster must not invent a trust level.""" + _install_registry(monkeypatch, _Registry({"alpha": _Plugin("alpha", [])}), trust=None) + + finding = _observe()[0] + assert finding.payload["roster"][0]["trust_level"] == "unverified" + assert _rows(finding)["trust"]["status"] == UNVERIFIABLE + + +def test_conflicts_are_surfaced(monkeypatch): + registry = _Registry( + {"alpha": _Plugin("alpha", [])}, + conflicts=[_Conflict("dup_tool", "alpha", "beta")], + ) + _install_registry(monkeypatch, registry) + + finding = _observe()[0] + assert finding.payload["conflicts"] == [ + {"tool_name": "dup_tool", "kept_plugin": "alpha", "rejected_plugin": "beta"} + ] + assert finding.severity is Severity.NOTABLE + + +# ── reachability must never claim more than it measured ────────────────────── + + +def test_world_model_driver_is_unverifiable_without_a_durable_trace(monkeypatch): + """The driver's own counts go to an in-memory pipeline observer, not a store. + + Absence of an admitted intent cannot tell "never ran" from "ran and the gate + correctly refused it", so the only honest verdict is ``unverifiable`` -- never + ``no_evidence``, which would read as a fault, and never ``wired``. + """ + from leapflow.monitor import evolution_producer as mod + + class _Obs: + def unresolved(self, **_kw): + return [{"observation_id": "o1", "result": {"error_type": "unknown_tool"}}] + + _install_registry(monkeypatch, _Registry({})) + monkeypatch.setattr( + mod.EvolutionProducer, + "_json_store", + staticmethod( + lambda layout_attr, *a, **k: _Obs() + if layout_attr == "capability_observations_path" + else None + ), + ) + + row = _rows(_observe()[0])["world_model_driver"] + assert row["status"] == UNVERIFIABLE + assert "not persisted" in row["evidence"] + assert "accepted_evidence_kinds" in row["next_step"] + + +def test_world_model_driver_is_wired_once_an_intent_is_admitted(monkeypatch): + """An admitted intent is the one durable trace that the driver reached the pipeline.""" + from leapflow.monitor import evolution_producer as mod + + class _Obs: + def unresolved(self, **_kw): + return [ + {"observation_id": "o1", "result": {"error_type": "world_model_intent"}}, + {"observation_id": "o2", "result": {"error_type": "unknown_tool"}}, + ] + + _install_registry(monkeypatch, _Registry({})) + monkeypatch.setattr( + mod.EvolutionProducer, + "_json_store", + staticmethod( + lambda layout_attr, *a, **k: _Obs() + if layout_attr == "capability_observations_path" + else None + ), + ) + + row = _rows(_observe()[0])["world_model_driver"] + assert row["status"] == WIRED + assert "1 admitted" in row["evidence"] + + +def test_module_existence_is_never_reported_as_wired(monkeypatch): + """The three awaiting-wiring segments have modules in the tree. + + Their presence must not read as evidence: a module with no caller is exactly + the failure this panel exists to expose. + """ + _install_registry(monkeypatch, _Registry({})) + + rows = _rows(_observe()[0]) + for key in ("effect_verification", "quarantine_feed", "reclamation"): + assert rows[key]["status"] == NO_EVIDENCE, key + assert rows[key]["next_step"], f"{key} must name what would close it" + + +def test_evidence_gate_reads_config_and_reports_not_admitted(monkeypatch): + from leapflow.monitor import evolution_producer as mod + + _install_registry(monkeypatch, _Registry({})) + monkeypatch.setattr( + mod.EvolutionProducer, + "_settings", + staticmethod(lambda: SimpleNamespace(accepted_evidence_kinds=(), evolution_authorising_origins=())), + ) + + row = _rows(_observe()[0])["evidence_gate"] + assert row["status"] == NOT_ADMITTED + assert "accepted_evidence_kinds" in row["next_step"] + + +def test_evidence_gate_is_wired_once_world_model_intent_is_admitted(monkeypatch): + from leapflow.monitor import evolution_producer as mod + + _install_registry(monkeypatch, _Registry({})) + monkeypatch.setattr( + mod.EvolutionProducer, + "_settings", + staticmethod( + lambda: SimpleNamespace( + accepted_evidence_kinds=("unknown_tool", "world_model_intent"), + evolution_authorising_origins=("world_model",), + ) + ), + ) + + rows = _rows(_observe()[0]) + assert rows["evidence_gate"]["status"] == WIRED + assert rows["authorising_origins"]["status"] == WIRED + + +def test_unreadable_settings_are_unverifiable_not_no_evidence(monkeypatch): + """"Could not read" and "nothing observed" are different answers.""" + from leapflow.monitor import evolution_producer as mod + + _install_registry(monkeypatch, _Registry({})) + monkeypatch.setattr(mod.EvolutionProducer, "_settings", staticmethod(lambda: None)) + + rows = _rows(_observe()[0]) + assert rows["evidence_gate"]["status"] == UNVERIFIABLE + assert rows["authorising_origins"]["status"] == UNVERIFIABLE + + +def test_store_backed_segments_report_evidence_when_data_exists(monkeypatch): + from leapflow.monitor import evolution_producer as mod + + class _Obs: + def unresolved(self, **_kw): + return [{"observation_id": "o1"}, {"observation_id": "o2"}] + + class _Queue: + def list_items(self, **_kw): + return [SimpleNamespace(status="PENDING"), SimpleNamespace(status="PROBATION")] + + class _Plans: + def latest(self): + return {"policy_decision": {"action": "propose"}} + + stores = { + "capability_observations_path": _Obs(), + "capability_proposal_queue_path": _Queue(), + "capability_plans_path": _Plans(), + } + _install_registry(monkeypatch, _Registry({})) + monkeypatch.setattr( + mod.EvolutionProducer, + "_json_store", + staticmethod(lambda layout_attr, *a, **k: stores.get(layout_attr)), + ) + + rows = _rows(_observe()[0]) + assert rows["observations"]["status"] == WIRED + assert "2 open" in rows["observations"]["evidence"] + assert rows["lifecycle"]["status"] == WIRED + assert "PENDING=1" in rows["lifecycle"]["evidence"] + assert rows["policy"]["status"] == WIRED + assert "propose" in rows["policy"]["evidence"] + + +def test_raising_store_is_unverifiable_and_does_not_fail_the_cycle(monkeypatch): + from leapflow.monitor import evolution_producer as mod + + class _Broken: + def unresolved(self, **_kw): + raise OSError("disk gone") + + def list_items(self, **_kw): + raise OSError("disk gone") + + def latest(self): + raise OSError("disk gone") + + _install_registry(monkeypatch, _Registry({})) + monkeypatch.setattr(mod.EvolutionProducer, "_json_store", staticmethod(lambda *a, **k: _Broken())) + + rows = _rows(_observe()[0]) + for key in ("observations", "lifecycle", "policy"): + assert rows[key]["status"] == UNVERIFIABLE, key + + +# ── severity and dedup ─────────────────────────────────────────────────────── + + +def test_quiet_state_is_info_not_an_alert(monkeypatch): + """An idle pipeline and an unadmitted evidence kind are correct, quiet states.""" + _install_registry( + monkeypatch, + _Registry({"alpha": _Plugin("alpha", [_Tool("a")])}), + trust=_Trust({"alpha": "PRODUCTION"}), + ) + + finding = _observe()[0] + assert finding.severity is Severity.INFO + assert any(row["status"] == NO_EVIDENCE for row in finding.payload["reachability"]) + + +def test_dedup_key_is_stable_for_unchanged_state_across_cycles(monkeypatch): + """The executor skips a duplicate dedup key, so an unchanged framework must + produce the same key even though the clock advanced.""" + registry = _Registry({"alpha": _Plugin("alpha", [_Tool("a")])}) + _install_registry(monkeypatch, registry, trust=_Trust({"alpha": "VERIFIED"})) + + first = _observe(now=1000.0)[0] + second = _observe(now=9999.0)[0] + assert first.dedup_key == second.dedup_key + # ...and the timestamp is still carried, so the finding is not undatable. + assert first.ts == 1000.0 + assert second.ts == 9999.0 + + +def test_dedup_key_changes_when_the_framework_changes(monkeypatch): + registry = _Registry({"alpha": _Plugin("alpha", [_Tool("a")])}) + _install_registry(monkeypatch, registry, trust=_Trust({"alpha": "DRAFT"})) + before = _observe()[0].dedup_key + + _install_registry(monkeypatch, registry, trust=_Trust({"alpha": "VERIFIED"})) + assert _observe()[0].dedup_key != before + + +def test_registry_version_change_changes_the_dedup_key(monkeypatch): + plugins = {"alpha": _Plugin("alpha", [_Tool("a")])} + _install_registry(monkeypatch, _Registry(plugins, version=1)) + before = _observe()[0].dedup_key + + _install_registry(monkeypatch, _Registry(plugins, version=2)) + assert _observe()[0].dedup_key != before + + +# ── payload bounds and contract ────────────────────────────────────────────── + + +def test_payload_is_bounded(monkeypatch): + """A producer that does not bound its payload pushes current findings out of the frame.""" + from leapflow.monitor import evolution_producer as mod + + plugins = { + f"p{i}": _Plugin(f"p{i}", [_Tool(f"t{i}_{j}", (f"cap.{i}.{j}",)) for j in range(6)]) + for i in range(80) + } + _install_registry(monkeypatch, _Registry(plugins)) + + payload = _observe()[0].payload + assert len(payload["roster"]) <= mod._MAX_ROSTER + assert len(payload["topology"]["nodes"]) <= mod._MAX_TOPOLOGY_NODES + assert len(payload["topology"]["edges"]) <= mod._MAX_TOPOLOGY_EDGES + assert len(payload["capability_map"]) <= mod._MAX_CAPABILITY_MAP + + +def test_capability_ownership_is_emitted_in_renderer_compatible_shapes(monkeypatch): + """The shipped EntityGraph renderer is a badge cloud, not a graph. + + It reads ``props.data`` through ``asArray``, which returns ``[]`` for anything + that is not a list, and shows each item's ``name``. A nodes/edges mapping + therefore renders an empty panel and reports no fault -- so the producer must + also emit the flat projections the view can actually bind. + """ + registry = _Registry( + {"alpha": _Plugin("alpha", [_Tool("a_read", ("read.file", "read.dir"))])} + ) + _install_registry(monkeypatch, registry) + + payload = _observe()[0].payload + + # Badge cloud: a list whose items carry ``name``. + assert isinstance(payload["capability_badges"], list) + assert payload["capability_badges"] == [{"name": "read.dir"}, {"name": "read.file"}] + + # Table: one row per (capability, tool, plugin) with the keys the columns read. + assert {"capability": "read.file", "tool": "a_read", "plugin": "alpha"} in payload[ + "capability_map" + ] + assert len(payload["capability_map"]) == 2 + + # The general graph stays for a future real renderer. + assert payload["topology"]["nodes"] and payload["topology"]["edges"] + + +def test_evolution_template_binds_only_shapes_its_renderers_read(): + """Guard the trap above at the template level, for this template. + + ``EntityGraph`` and ``Table`` both read ``props.data``; a template binding a + mapping to either renders headings over nothing. Asserted here rather than + only in the SDUI suite because the payload contract is this producer's. + """ + from leapflow.dashboard.templates import TemplateLibrary + + raw = TemplateLibrary().load("evolution") + assert raw is not None, "evolution template must ship" + + list_valued = { + "evolution.reachability", + "evolution.roster", + "evolution.conflicts", + "evolution.capability_map", + "evolution.capability_badges", + "evolution.timeline", + "evolution.mutation_matrix", + "evolution.trace_feed", + "evolution.unadmitted", + "evolution.fiber_transitions", + "evolution.trust_mix", + "evolution.reachability_mix", + "evolution.provenance_mix", + "evolution.reclaim_candidates", + "evolution.summary.suggestions", + } + binds: list[tuple[str, str]] = [] + + def walk(node: object) -> None: + if isinstance(node, dict): + props = node.get("props") + if node.get("type") in ("EntityGraph", "Table") and isinstance(props, dict): + bind = props.get("bind") + if isinstance(bind, str): + binds.append((str(node.get("type")), bind)) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + + walk(raw) + assert binds, "template must bind at least one data-driven panel" + for component, bind in binds: + assert bind in list_valued, f"{component} binds {bind!r}, which is not a list-valued key" + + +def test_roster_records_whether_a_plugin_was_ever_selected(monkeypatch): + """``ever_used`` is a durable fact, chosen over a live call counter on purpose. + + A registered, unselectable, never-used artifact is the reclamation case, and + that question survives between cycles. A raw counter would not: this finding + dedups on a content fingerprint, so a per-tick metric either churns a row + every cycle or freezes on the board while still looking current. + """ + registry = _Registry({"used": _Plugin("used", []), "idle": _Plugin("idle", [])}) + _install_registry(monkeypatch, registry, usage=_Usage({"used": 12, "idle": 0})) + + roster = {row["plugin_id"]: row for row in _observe()[0].payload["roster"]} + assert roster["used"]["ever_used"] == "yes" + assert roster["idle"]["ever_used"] == "no" + + +def test_roster_renders_no_live_metric_columns(monkeypatch): + """Guard against reintroducing a value that freezes while looking current. + + Any rendered field that changes every cycle must either be in the fingerprint + (churning a row per tick) or be absent. Error rates and call counts belong to + the ``plugin_health`` domain, which alerts on them directly. + """ + _install_registry( + monkeypatch, + _Registry({"alpha": _Plugin("alpha", [])}), + usage=_Usage({"alpha": 5}), + ) + + row = _observe()[0].payload["roster"][0] + assert "error_rate" not in row + assert "total_calls" not in row + + +def test_every_rendered_roster_field_is_covered_by_the_fingerprint(monkeypatch): + """A rendered value left out of the fingerprint freezes on the page. + + The executor skips a finding whose dedup key already exists, so a field the + board shows but the fingerprint ignores keeps its first-observed value + forever while appearing live. Asserted by flipping each field in turn. + """ + from leapflow.monitor.evolution_producer import EvolutionProducer as P + + base = { + "plugin_id": "alpha", + "fiber_state": "active", + "trust_level": "DRAFT", + "trust_class": "new_unproven", + "selectable": True, + "ever_used": False, + "tool_count": 1, + } + payload = { + "summary": {"registry_version": 1, "registry_readable": True}, + "roster": [base], + "conflicts": [], + "reachability": [{"key": "observations", "status": "no_evidence"}], + } + reference = P._fingerprint(payload) + + # ``trust_class`` and ``tool_count`` are derived from covered fields + # (trust_level / the tool list), so flipping them alone is not required to + # move the fingerprint; every independently-observed field must. + for field, changed in ( + ("fiber_state", "disposed"), + ("trust_level", "VERIFIED"), + ("selectable", False), + ("ever_used", True), + ): + variant = dict(payload) + variant["roster"] = [{**base, field: changed}] + assert P._fingerprint(variant) != reference, f"{field} is rendered but not fingerprinted" + + +def test_producer_declares_the_framework_evolution_domain(): + assert EvolutionProducer().domain == "framework_evolution" + + +def test_snapshot_only_state_is_declared_not_silently_empty(monkeypatch): + """With no decision record there is no causal history; the view must say why.""" + _install_registry(monkeypatch, _Registry({})) + + payload = _observe()[0].payload + assert payload["episodes"] == [] + assert payload["timeline"] == [] + assert payload["degraded"] is True + assert payload["degraded_reason"] + # Nothing has been closed, so there is no closure to qualify. Claiming an + # L2 caveat here would attach a warning to an empty table. + assert payload["summary"]["l2_only_closures"] is False + + +def test_unrebuildable_history_is_not_reported_as_no_activity(monkeypatch): + """"Could not look" and "nothing to see" must not share one explanation. + + Both leave the timeline empty, so the only thing separating a fault from a + quiet system is what the panel says about it. Reporting an unreadable store as + "nothing has been recorded" would present a local defect as an absence of + activity -- the same conflation the reachability rows exist to prevent. + """ + from leapflow.monitor.evolution_producer import EvolutionProducer + + _install_registry(monkeypatch, _Registry({})) + + class _Broken(EvolutionProducer): + def _episodes(self, ctx): + return None + + class _Empty(EvolutionProducer): + def _episodes(self, ctx): + return () + + broken = asyncio.run(_Broken().observe(_ctx()))[0].payload + empty = asyncio.run(_Empty().observe(_ctx()))[0].payload + + assert broken["degraded"] is True and empty["degraded"] is True + assert broken["degraded_kind"] == "unverifiable" + assert empty["degraded_kind"] == "no_evidence" + assert broken["degraded_reason"] != empty["degraded_reason"] + assert "could not be read" in broken["degraded_reason"] + # The fault must not be described as an absence of activity. + assert "No capability decision has been recorded" not in broken["degraded_reason"] + + +def test_every_rendered_episode_field_is_covered_by_the_fingerprint(): + """Same freezing hazard as the roster, applied to the timeline. + + Only independently-observed fields need their own coverage: + ``verification_tier`` is derived from ``gap_closure``, and ``outcome`` from + ``gap_closure``/``mutation_action``, both of which are covered. A change to + ``driver``/``capability``/``hypothesis`` can only arrive with a new decision + record, which brings a new ``episode_id``. + """ + from leapflow.monitor.evolution_producer import EvolutionProducer as P + + base = { + "episode_id": "ep-1", + "status": "committed", + "gap_closure": "resolved", + "mutation_action": "install", + "trust_now": "DRAFT", + } + payload = { + "summary": {"registry_version": 1, "registry_readable": True}, + "roster": [], + "conflicts": [], + "reachability": [], + "episodes": [base], + } + reference = P._fingerprint(payload) + + for field, changed in ( + ("episode_id", "ep-2"), + ("status", "aborted"), + ("gap_closure", "reopened"), + ("mutation_action", "rollback"), + ("trust_now", "VERIFIED"), + ): + variant = dict(payload) + variant["episodes"] = [{**base, field: changed}] + assert P._fingerprint(variant) != reference, ( + f"{field} is rendered but not fingerprinted; the timeline would freeze" + ) + + # A new episode must refresh the board even if the framework itself is idle. + grown = dict(payload) + grown["episodes"] = [base, {**base, "episode_id": "ep-2"}] + assert P._fingerprint(grown) != reference + + +def test_every_closed_vocabulary_the_payload_emits_is_translated(): + """Payload enums are rendered as table cells, so they need translating too. + + The client passes every string cell through its translator with a raw + fallback, so an untranslated enum does not fail -- it renders English + snake_case inside an otherwise localised page. The template-literal contract + cannot catch this, because these words are *data*, and that gap is exactly how + 'declared_fitness' and 'no_evidence' reached five locales untranslated. + + Identifiers (plugin ids, tool names, capabilities) are correctly excluded: + they are names, not vocabulary. + """ + import sys + + sys.path.insert(0, "tests") + from test_dashboard_i18n_static import _translation_tables + + from leapflow.domain.evolution_trace import ( + ABORTED, + COMMITTED, + CONFORMANCE, + DECLARED_FITNESS, + NOT_APPLICABLE, + OBSERVED_EFFECT, + OPEN, + REOPENED, + RESOLVED, + STILL_OPEN, + ) + from leapflow.monitor import evolution_producer as ep + + vocabulary = { + # reachability + ep.WIRED, ep.NO_EVIDENCE, ep.UNVERIFIABLE, ep.NOT_ADMITTED, + # trust class + level + ep._TRUST_CLASS_FROZEN, "unverified", *ep._TRUST_CLASS.values(), *ep._TRUST_CLASS, + # rendered booleans + ep._YES, ep._NO, + # provenance: the distinction the board exists to report + ep._BUILT_IN, ep._SELF_ACQUIRED, + # episode + gap vocabularies + COMMITTED, OPEN, ABORTED, RESOLVED, REOPENED, STILL_OPEN, NOT_APPLICABLE, + CONFORMANCE, DECLARED_FITNESS, OBSERVED_EFFECT, + # drivers the ledger can classify + "world_model", "unknown_tool", "environment_probe", "manual", "unknown", + # mutation actions + "install", "reload", "disable", "remove", "rollback", "none", + } + tables = _translation_tables() + assert tables, "no translation tables discovered" + for locale, known in tables.items(): + if locale == "en": + continue + missing = sorted(word for word in vocabulary if word not in known) + assert not missing, f"{locale} renders these payload enums untranslated: {missing}" + + +def test_rendered_flags_are_translatable_words_not_json_literals(): + """A boolean cell reaches the page as ``true``/``false`` in every language. + + The client's value translator only handles strings, so a raw bool bypasses it + entirely. Emitting a vocabulary key keeps the column localisable. + """ + from leapflow.monitor import evolution_producer as ep + + row = ep.EvolutionProducer()._roster_row("p", [], {}, None, None) + assert row["selectable"] in (ep._YES, ep._NO) + assert row["ever_used"] in (ep._YES, ep._NO) + assert not isinstance(row["selectable"], bool) + assert not isinstance(row["ever_used"], bool) + + +def test_no_section_renders_as_a_bare_title(): + """An empty panel under a heading reads as a load failure, not as 'nothing yet'. + + Guards the ``when``-on-section rule: putting the condition on the child instead + left seven titled sections empty on a fresh profile. + """ + from leapflow.dashboard.templates import TemplateLibrary + + for payload in ({}, {"summary": {}}, {"roster": [], "reachability": []}): + spec = TemplateLibrary().render("evolution", {"evolution": payload}) + empty: list[str] = [] + + def walk(nodes): + for node in nodes: + children = node.get("children") or [] + if node.get("type") == "Section" and not children: + empty.append(str((node.get("props") or {}).get("title"))) + walk(children) + + walk(spec["root"]) + assert not empty, f"sections rendered with a title and no content: {empty}" + + +def test_the_default_tab_is_never_blank(): + """Every visitor lands on the first tab; an empty pane reads as a broken page. + + The hazard is specific to tabs: with no causal history the other panes still + have content, so the failure is invisible unless the first pane is checked on + its own. Exercised across the states a fresh profile actually passes through. + """ + from leapflow.dashboard.templates import TemplateLibrary + + payloads = ( + # Nothing at all but a summary: the state right after the first cycle. + {"summary": {"active_plugins": 0}, "degraded": True}, + # A framework with plugins but no evolution yet -- the common case. + { + "summary": {"active_plugins": 17, "tool_count": 55}, + "degraded": True, + "roster": [{"plugin_id": "a", "provenance": "built_in"}], + "reachability": [{"stage": "s", "status": "no_evidence"}], + }, + ) + for payload in payloads: + spec = TemplateLibrary().render("evolution", {"evolution": payload}) + + def first_tab(nodes): + for node in nodes: + if node.get("type") == "Tabs": + tabs = node.get("children") or [] + return tabs[0] if tabs else None + found = first_tab(node.get("children") or []) + if found is not None: + return found + return None + + tab = first_tab(spec["root"]) + assert tab is not None, "template no longer has tabs" + assert tab.get("children"), ( + f"the default tab rendered empty for payload keys {sorted(payload)}" + ) diff --git a/tests/test_evolution_tap.py b/tests/test_evolution_tap.py new file mode 100644 index 00000000..c4e272aa --- /dev/null +++ b/tests/test_evolution_tap.py @@ -0,0 +1,570 @@ +"""P2 collection layer: the tap, the sink, the trace store, and the four probes. + +The probes exist for one reason: to record facts that no store retains. So the +tests that matter most here are the ones asserting a probe fires *where nothing +else would have noticed*, and that with no sink installed the system behaves +exactly as it did before the probes existed. + +The four probe sites, and why each earns its keep: + +* the plugin registry's version bump -- live conflicts and the version history are + in memory only; +* the trust level transition -- only the *current* level is persisted, never the + moment it moved or the direction; +* the world model's drive -- an intent proposed and **not admitted** writes no + observation, so it exists nowhere at all; +* a lifecycle record opening -- the queue holds the item but not what it was + opened for. +""" + +from __future__ import annotations + +import pytest +from types import SimpleNamespace + +from leapflow.domain.evolution_trace import EvolutionStage, EvolutionTrace +from leapflow.evolution import LedgerEvolutionSink +from leapflow.storage.evolution_trace_store import JsonEvolutionTraceStore +from leapflow.telemetry import evolution_tap + + +class _Collector: + """Minimal structural sink.""" + + def __init__(self) -> None: + self.traces: list[EvolutionTrace] = [] + + def record(self, trace: EvolutionTrace) -> None: + self.traces.append(trace) + + +@pytest.fixture(autouse=True) +def _clean_sink(): + """No test may leak a sink: the tap is process-global.""" + evolution_tap.install_sink(None) + yield + evolution_tap.install_sink(None) + + +def _kinds(collector: _Collector) -> list[str]: + return [t.kind for t in collector.traces] + + +# ── the tap ────────────────────────────────────────────────────────────────── + + +def test_no_sink_means_no_op_and_no_error(): + """The default state, and the only one an in-process CLI ever sees.""" + assert evolution_tap.is_enabled() is False + evolution_tap.emit_trace(EvolutionStage.ACT, "anything", summary="s") # must not raise + + +def test_a_broken_sink_cannot_break_the_caller(): + """Telemetry is never allowed an opinion about the operation it observes.""" + + class _Broken: + def record(self, trace): + raise RuntimeError("sink exploded") + + evolution_tap.install_sink(_Broken()) + evolution_tap.emit_trace(EvolutionStage.ACT, "kind") # must not raise + + +def test_install_is_last_wins_not_fan_out(): + """Two live sinks would double-count every fact.""" + first, second = _Collector(), _Collector() + evolution_tap.install_sink(first) + evolution_tap.install_sink(second) + evolution_tap.emit_trace(EvolutionStage.ACT, "kind") + assert not first.traces + assert len(second.traces) == 1 + + +# ── the sink ───────────────────────────────────────────────────────────────── + + +def test_record_does_no_io_and_flush_does(): + """The hot/cold split the probe contract depends on.""" + + class _Store: + def __init__(self): + self.batches = [] + + def append(self, traces): + rows = list(traces) + self.batches.append(rows) + return len(rows) + + store = _Store() + sink = LedgerEvolutionSink(store=store) + sink.record(EvolutionTrace(stage=EvolutionStage.ACT, kind="a")) + sink.record(EvolutionTrace(stage=EvolutionStage.ACT, kind="b")) + assert store.batches == [] # nothing written yet + assert sink.flush() == 2 + assert len(store.batches) == 1 # one write for the batch, not one per trace + assert sink.flush() == 0 # drained + + +def test_overflow_drops_oldest_and_says_so(): + """A silent drop would make the panel quietly incomplete.""" + sink = LedgerEvolutionSink(store=None, buffer_size=2) + for i in range(5): + sink.record(EvolutionTrace(stage=EvolutionStage.ACT, kind=f"k{i}")) + assert sink.stats["dropped"] == 3 + assert sink.stats["recorded"] == 5 + # Newest survive: a burst means churn, and where it ended up is what matters. + assert [t.kind for t in sink.pending()] == ["k3", "k4"] + + +def test_flush_failure_loses_traces_rather_than_retrying_forever(): + class _Broken: + def append(self, traces): + raise OSError("disk gone") + + sink = LedgerEvolutionSink(store=_Broken()) + sink.record(EvolutionTrace(stage=EvolutionStage.ACT, kind="a")) + assert sink.flush() == 0 + assert sink.pending() == () # drained, not retried + assert sink.stats["dropped"] == 1 + + +# ── the store ──────────────────────────────────────────────────────────────── + + +def test_store_round_trip_is_newest_first(tmp_path): + store = JsonEvolutionTraceStore(tmp_path / "t.json") + store.append([{"trace_id": "a", "ts": 1.0}, {"trace_id": "b", "ts": 3.0}]) + store.append([{"trace_id": "c", "ts": 2.0}]) + assert [r["trace_id"] for r in store.list_traces()] == ["b", "c", "a"] + + +def test_store_trims_to_the_newest(tmp_path): + """Retention is a count, because the newest traces are what an incident needs.""" + store = JsonEvolutionTraceStore(tmp_path / "t.json", max_traces=3) + store.append([{"trace_id": f"t{i}", "ts": float(i)} for i in range(10)]) + kept = [r["trace_id"] for r in store.list_traces()] + assert kept == ["t9", "t8", "t7"] + assert store.count() == 3 + + +def test_corrupt_store_reads_as_empty_rather_than_raising(tmp_path): + path = tmp_path / "t.json" + path.write_text("{ not json", encoding="utf-8") + store = JsonEvolutionTraceStore(path) + assert store.list_traces() == [] + assert store.append([{"trace_id": "a", "ts": 1.0}]) == 1 + + +# ── probe: registry version bump ───────────────────────────────────────────── + + +def test_registry_version_bump_is_a_single_observed_point(): + """Every increment must be observable, not just the one method a probe sat in. + + ``notify_mutation`` -- the site the original design named -- is reached by only + two scope-disposal callers, so a probe there would have missed plugin + registration, assembly, publication and every unregister path. + """ + from leapflow.plugins.registry import ToolPluginRegistry + + source = ToolPluginRegistry.__init__.__code__.co_consts # touch to ensure import + assert source is not None + import inspect + + body = inspect.getsource(ToolPluginRegistry) + # One statement increments the counter, inside the single bump method. + assert body.count("self._version += 1") == 1 + assert "_bump_version" in body + + +def test_registry_mutation_emits_a_trace_and_marks_the_phase(): + """Boot composition and a later runtime change must be distinguishable. + + Otherwise every daemon start replays the initial plugin load and buries the + rare real mutation under a boot log. + """ + from leapflow.plugins.registry import ToolPluginRegistry + + collector = _Collector() + evolution_tap.install_sink(collector) + + registry = ToolPluginRegistry() + registry.notify_mutation() + assert _kinds(collector) == ["registry_scope_disposed"] + trace = collector.traces[0] + assert trace.stage is EvolutionStage.ACT + # Before assemble() this is composition, not evolution. + assert trace.detail["phase"] == "composition" + assert trace.correlation["registry_version"] == "1" + + registry._assembled = True + registry.notify_mutation() + assert collector.traces[-1].detail["phase"] == "runtime" + + +def test_registry_still_bumps_when_the_sink_is_broken(): + """The registry's own job must survive a telemetry failure.""" + from leapflow.plugins.registry import ToolPluginRegistry + + class _Broken: + def record(self, trace): + raise RuntimeError("boom") + + evolution_tap.install_sink(_Broken()) + registry = ToolPluginRegistry() + before = registry.version + registry.notify_mutation() + assert registry.version == before + 1 + + +# ── probe: trust transition ────────────────────────────────────────────────── + + +def test_trust_transition_is_emitted_where_the_flush_already_detects_it(): + """Only the current level is persisted; the move itself lives nowhere else.""" + from leapflow.engine.session_factory import _PersistingTrustLedger + + collector = _Collector() + evolution_tap.install_sink(collector) + + ledger = _PersistingTrustLedger(candidate_at=2, verified_at=99, production_at=99) + ledger.record_success("p") + assert collector.traces == [] # no level change, no trace + ledger.record_success("p") # crosses candidate_at + + assert _kinds(collector) == ["trust_transition"] + trace = collector.traces[0] + assert trace.stage is EvolutionStage.LEARN + assert trace.detail["from"] == "DRAFT" + assert trace.detail["to"] == "CANDIDATE" + assert trace.detail["frozen"] is False + + +def test_a_hard_failure_is_traced_as_frozen_even_at_an_unchanged_level(): + """DRAFT alone cannot say whether a plugin is new or disqualified.""" + from leapflow.engine.session_factory import _PersistingTrustLedger + + collector = _Collector() + evolution_tap.install_sink(collector) + + ledger = _PersistingTrustLedger() + ledger.record_failure("p", hard=True) # already DRAFT; level does not move + + assert _kinds(collector) == ["trust_frozen"] + assert collector.traces[0].detail["frozen"] is True + assert collector.traces[0].detail["hard_failure"] is True + + +def test_the_pure_trust_ledger_gains_no_telemetry_dependency(): + """``PluginTrustLedger`` is a zero-dependency domain object and stays one.""" + import inspect + + from leapflow.learning import plugin_trust + + source = inspect.getsource(plugin_trust) + assert "evolution_tap" not in source + assert "emit_trace" not in source + + +# ── probe: the world model's unadmitted proposals ──────────────────────────── + + +def test_unadmitted_intents_are_recorded_because_nothing_else_records_them(): + """The single most valuable trace: a proposal that entered no pipeline. + + It writes no observation, so without this the board shows an idle pipeline + while the world model proposes on every session. + """ + import asyncio + from dataclasses import dataclass + + from leapflow.learning.world_model_driver import WorldModelEvolutionDriver + + @dataclass + class _Intent: + intent_id: str = "wmi-1" + capability: str = "ui.chat.send" + hypothesis: str = "no way to send a chat message" + confidence: float = 0.8 + + def to_dict(self): + return { + "intent_id": self.intent_id, + "capability": self.capability, + "hypothesis": self.hypothesis, + "confidence": self.confidence, + } + + def to_observation_result(self, **_kw): + return {"error_type": "world_model_intent", "capability": self.capability} + + class _Teacher: + async def grade_and_propose(self, trajectory, goal): + return type("V", (), {"grades": (), "intents": (_Intent(),)})() + + class _RejectingIntake: + """Mirrors a profile where ``world_model_intent`` is not an accepted kind.""" + + def observe_result(self, *_a, **_kw): + return None + + def requirements(self, **_kw): + return () + + collector = _Collector() + evolution_tap.install_sink(collector) + + driver = WorldModelEvolutionDriver(teacher=_Teacher(), intake=_RejectingIntake()) + result = asyncio.run(driver.drive(trajectory=[{"step": 1}], goal="g")) + + assert result.admitted_observation_ids == () + assert _kinds(collector) == ["world_model_drive"] + detail = collector.traces[0].detail + assert detail["not_admitted_reason"] + assert detail["intents"][0]["hypothesis"] == "no way to send a chat message" + assert detail["admitted_observation_ids"] == [] + + +def test_producer_surfaces_unadmitted_proposals_from_traces(): + """The payload must carry the model's reasoning, not just a count.""" + from leapflow.monitor.evolution_producer import EvolutionProducer + + traces = [ + { + "trace_id": "t1", + "stage": "observe", + "kind": "world_model_drive", + "summary": "teacher proposed 1, admitted 0", + "detail": { + "not_admitted_reason": "world_model_intent is not in accepted_evidence_kinds", + "intents": [ + {"capability": "ui.chat.send", "hypothesis": "no way to send", "confidence": 0.8} + ], + }, + } + ] + rows = EvolutionProducer._unadmitted(traces) + assert rows == [ + { + "capability": "ui.chat.send", + "hypothesis": "no way to send", + "confidence": "80%", + "reason": "world_model_intent is not in accepted_evidence_kinds", + } + ] + + +def test_composition_traces_are_kept_out_of_the_live_feed(): + """A boot replay would bury the rare real mutation.""" + from leapflow.monitor.evolution_producer import EvolutionProducer + + traces = [ + {"trace_id": "a", "stage": "act", "kind": "registry_assembled", + "summary": "boot", "detail": {"phase": "composition"}}, + {"trace_id": "b", "stage": "act", "kind": "registry_plugin_registered", + "summary": "install", "detail": {"phase": "runtime"}}, + {"trace_id": "c", "stage": "learn", "kind": "trust_frozen", + "summary": "frozen", "detail": {"phase": "runtime"}}, + ] + feed = EvolutionProducer._trace_feed(traces) + assert [row["summary"] for row in feed] == ["install", "frozen"] + # A freeze is the one trace worth interrupting for. + assert feed[-1]["severity"] == "alert" + + +def test_traces_participate_in_the_dedup_fingerprint(): + """A trust transition does not move the registry version. + + Without traces in the key the executor would skip the write and the live feed + would freeze on the page while still looking current. + """ + from leapflow.monitor.evolution_producer import EvolutionProducer as P + + base = { + "summary": {"registry_version": 1, "registry_readable": True}, + "roster": [], + "conflicts": [], + "reachability": [], + "episodes": [], + "traces": [{"trace_id": "t1"}], + } + reference = P._fingerprint(base) + grown = {**base, "traces": [{"trace_id": "t1"}, {"trace_id": "t2"}]} + assert P._fingerprint(grown) != reference + + +# ── P4: fiber transitions recovered by snapshot diff ───────────────────────── + + +def test_fiber_transitions_need_a_baseline_before_reporting_anything(): + """The first cycle after a restart has nothing to compare against.""" + from leapflow.monitor.evolution_producer import EvolutionProducer + + producer = EvolutionProducer() + assert producer._fiber_transitions({"a": "active"}, readable=True) == [] + # Second cycle, unchanged: still nothing to report. + assert producer._fiber_transitions({"a": "active"}, readable=True) == [] + + +def test_the_load_retry_path_is_visible_only_through_the_diff(): + """``LOADING -> FAILED -> LOADING`` bumps no registry version, so no probe sees it.""" + from leapflow.monitor.evolution_producer import EvolutionProducer + + producer = EvolutionProducer() + producer._fiber_transitions({"p": "loading"}, readable=True) # baseline + failed = producer._fiber_transitions({"p": "failed"}, readable=True) + retry = producer._fiber_transitions({"p": "loading"}, readable=True) + + assert failed == [{"plugin_id": "p", "from": "loading", "to": "failed", "kind": "moved"}] + assert retry == [{"plugin_id": "p", "from": "failed", "to": "loading", "kind": "moved"}] + + +def test_appearance_and_disposal_are_distinguished_from_a_move(): + from leapflow.monitor.evolution_producer import EvolutionProducer + + producer = EvolutionProducer() + producer._fiber_transitions({"a": "active"}, readable=True) + rows = producer._fiber_transitions({"a": "active", "b": "pending"}, readable=True) + assert rows == [{"plugin_id": "b", "from": "", "to": "pending", "kind": "appeared"}] + + rows = producer._fiber_transitions({"a": "active"}, readable=True) + assert rows == [{"plugin_id": "b", "from": "pending", "to": "", "kind": "gone"}] + + +def test_an_unreadable_registry_fabricates_no_mass_event(): + """Diffing against an empty snapshot would report every plugin as disposed. + + And replacing the baseline with it would report every plugin as newly appeared + on the next good cycle -- two fabricated mass events from one transient failure. + """ + from leapflow.monitor.evolution_producer import EvolutionProducer + + producer = EvolutionProducer() + producer._fiber_transitions({"a": "active", "b": "active"}, readable=True) + + assert producer._fiber_transitions({}, readable=False) == [] + # Baseline survived, so the next good cycle sees no change either. + assert producer._fiber_transitions({"a": "active", "b": "active"}, readable=True) == [] + + +def test_fiber_transitions_are_covered_by_the_fingerprint(): + """A retry leaves the roster identical, so only the delta can refresh the board.""" + from leapflow.monitor.evolution_producer import EvolutionProducer as P + + quiet = { + "summary": {"registry_version": 1, "registry_readable": True}, + "roster": [], "conflicts": [], "reachability": [], "episodes": [], "traces": [], + } + noisy = { + **quiet, + "fiber_transitions": [ + {"plugin_id": "p", "from": "failed", "to": "loading", "kind": "moved"} + ], + } + assert P._fingerprint(noisy) != P._fingerprint(quiet) + + +# ── P3: event-driven refresh ────────────────────────────────────────────────── + + +def test_the_sink_publishes_every_trace_it_buffers(): + """Publication is per-trace so a mutation can refresh the board immediately.""" + published: list = [] + sink = LedgerEvolutionSink(store=None, publish=published.append) + sink.record(EvolutionTrace(stage=EvolutionStage.ACT, kind="registry_plugin_registered")) + assert [t.kind for t in published] == ["registry_plugin_registered"] + + +def test_a_failing_publisher_cannot_lose_the_trace(): + """Buffering must survive a broken event bus: the durable record matters more.""" + + def _broken(_trace): + raise RuntimeError("bus down") + + sink = LedgerEvolutionSink(store=None, publish=_broken) + sink.record(EvolutionTrace(stage=EvolutionStage.ACT, kind="k")) + assert len(sink.pending()) == 1 + + +def test_composition_traces_are_not_published_as_events(): + """Boot replays the plugin load; publishing it would fire the watch to say nothing. + + Asserted against the daemon's real publisher factory rather than a copy of its + rule, because a second implementation of the filter could disagree with it. + """ + import asyncio + + from leapflow.daemon.monitor_coordinator import MonitorCoordinator + + seen: list[str] = [] + + class _Bus: + async def handle_event(self, event_type, payload): + seen.append(event_type) + + async def _drive(): + publisher = MonitorCoordinator()._make_evolution_publisher( + SimpleNamespace(event_bus=_Bus()) + ) + assert publisher is not None + publisher( + EvolutionTrace( + stage=EvolutionStage.ACT, kind="registry_assembled", + detail={"phase": "composition"}, + ) + ) + publisher( + EvolutionTrace( + stage=EvolutionStage.ACT, kind="registry_plugin_registered", + detail={"phase": "runtime"}, + ) + ) + await asyncio.sleep(0.05) # let the scheduled coroutines run + + asyncio.run(_drive()) + assert seen == ["evolution.registry_plugin_registered"] + + +def test_the_publisher_is_absent_rather_than_broken_without_a_bus(): + """No event bus is a normal state (in-process CLI), not a failure to report.""" + from leapflow.daemon.monitor_coordinator import MonitorCoordinator + + coordinator = MonitorCoordinator() + assert coordinator._make_evolution_publisher(SimpleNamespace()) is None + assert coordinator._make_evolution_publisher(SimpleNamespace(event_bus=object())) is None + + +def test_the_live_watch_is_armed_alongside_the_polled_one(): + """State needs polling (it has no event); change needs events (polling is late). + + Both are armed on the same domain, and the content fingerprint makes the + overlap free -- an unchanged framework dedups the second finding away. + """ + from leapflow.daemon.monitor_coordinator import MonitorCoordinator + + entries = { + name: (trigger, at_once) + for name, domain, trigger, at_once in MonitorCoordinator._DEFAULT_WATCHES + if domain == "framework_evolution" + } + assert entries == { + # Runs at once: it reads live registry state, so its first answer is already + # correct and a ten-minute blank board would be pure loss. + "framework-evolution": ("10m", True), + "framework-evolution-live": ("event:evolution.*", False), + } + + +def test_only_live_state_producers_are_brought_forward(): + """An accumulating producer has nothing true to say before it has accumulated. + + Bringing every interval watch forward published an empty hardware digest that + then stood as the newest finding until the next interval elapsed, so the board + reported zero sample windows on a bench that was sampling. + """ + from leapflow.daemon.monitor_coordinator import MonitorCoordinator + + at_once = { + name for name, _d, _t, flag in MonitorCoordinator._DEFAULT_WATCHES if flag + } + assert at_once == {"framework-evolution"} diff --git a/tests/test_evolution_verify_and_govern.py b/tests/test_evolution_verify_and_govern.py new file mode 100644 index 00000000..05c274ab --- /dev/null +++ b/tests/test_evolution_verify_and_govern.py @@ -0,0 +1,310 @@ +"""WM-6 / LF-10 / A-4 / P5: verify, then govern. + +* **WM-6** -- an acquired capability is verified by its *observed effect*, not by + conformance (`PluginValidator`) or by *declared* fitness (re-resolution). v0.5 and + v0.7 both recorded that gap. +* **LF-10** -- artifacts that keep failing verification are reclaimed by the + existing governor; the residual case (never selected at all) is found + conservatively by `UnselectableArtifactReaper`. +* **A-4** -- quarantine finally has a feed, split so the hot path stays trivial and + governance runs on a cold path. +* **P5** -- an enforcement mode where only listed requirement origins may drive an + acquisition; the executable form of "the world model is the first driver". +""" + +from __future__ import annotations + +import asyncio + +from leapflow.domain.capability_requirement import CapabilityRequirement +from leapflow.domain.evolution_intent import EvolutionIntent +from leapflow.learning.capability_effect_verifier import ( + EFFECT_ABSENT, + EFFECT_UNREPORTED, + EXECUTION_FAILED, + NO_OUTCOME, + UNVERIFIABLE, + VERIFIED, + CapabilityEffectVerifier, + UnselectableArtifactReaper, +) +from leapflow.learning.outcome_governance_feed import ( + QuarantineCandidateTracker, + drain_quarantine_candidates, + filter_authorised, + origin_may_authorise, +) + + +def _requirement(expected_effect: str = "the reply appears in the thread"): + intent = EvolutionIntent.create( + "chat.reply", "the send path silently no-ops", expected_effect=expected_effect + ) + return intent.to_requirement() + + +# ── WM-6: verification by observed effect ───────────────────────────────────── + + +def test_matching_effect_verifies(): + verdict = CapabilityEffectVerifier().verify( + _requirement(), + {"ok": True, "observed_effect": "the reply appears in the thread"}, + plugin_id="gen1", + ) + assert verdict.verified is True + assert verdict.reason == VERIFIED + assert verdict.should_record_outcome is True + + +def test_absent_effect_fails_even_when_the_call_succeeded(): + """The core of WM-6: 'the tool returned ok' is not 'the capability worked'.""" + verdict = CapabilityEffectVerifier().verify( + _requirement(), + {"ok": True, "observed_effect": "nothing happened"}, + plugin_id="gen1", + ) + assert verdict.verified is False + assert verdict.reason == EFFECT_ABSENT + assert verdict.should_record_outcome is True + + +def test_execution_failure_is_a_decided_negative(): + verdict = CapabilityEffectVerifier().verify( + _requirement(), {"ok": False, "observed_effect": "exception"}, plugin_id="gen1" + ) + assert verdict.verified is False + assert verdict.reason == EXECUTION_FAILED + + +def test_no_outcome_is_unverifiable_not_failed(): + verdict = CapabilityEffectVerifier().verify(_requirement(), None, plugin_id="gen1") + assert verdict.verified is None + assert verdict.reason == NO_OUTCOME + assert verdict.should_record_outcome is False + + +def test_missing_declaration_is_unverifiable_not_failed(): + """A metadata omission must not quarantine a healthy plugin.""" + requirement = CapabilityRequirement.create("chat.reply", "unknown_tool") + verdict = CapabilityEffectVerifier().verify( + requirement, {"ok": True, "observed_effect": "sent"}, plugin_id="gen1" + ) + assert verdict.verified is None + assert verdict.reason == UNVERIFIABLE + assert verdict.should_record_outcome is False + + +def test_a_silent_tool_is_unverifiable_not_refuted(): + """Absence of evidence is not evidence of absence. + + A handler written before the effect convention succeeds and says nothing. Refuting + that would demote it and quarantine it after three calls, punishing a plugin for a + reporting omission rather than for failing. + """ + verdict = CapabilityEffectVerifier().verify( + _requirement(), {"ok": True, "observed_effect": ""}, plugin_id="gen1" + ) + assert verdict.verified is None + assert verdict.reason == EFFECT_UNREPORTED + assert verdict.should_record_outcome is False + + +def test_stopwords_alone_do_not_verify(): + """'the in of' overlapping must not be read as the effect occurring.""" + verdict = CapabilityEffectVerifier().verify( + _requirement("the reply appears in the thread"), + {"ok": True, "observed_effect": "the of in and it"}, + plugin_id="gen1", + ) + assert verdict.verified is False + + +def test_verdict_feeds_the_governor_and_quarantines_a_useless_artifact(tmp_path): + """WM-6 + LF-10: repeated verification failure reclaims the artifact.""" + from leapflow.learning.plugin_trust import PluginTrustLedger + from leapflow.plugins.lifecycle_governor import LifecycleGovernor + from leapflow.storage.capability_proposal_queue import JsonCapabilityProposalQueue + from leapflow.storage.plugin_outcome_store import JsonPluginOutcomeStore + + disabled: list[str] = [] + + class _Actor: + async def disable(self, *, plugin_id): + disabled.append(plugin_id) + return {"ok": True} + + queue = JsonCapabilityProposalQueue(tmp_path / "q.json") + item = queue.enqueue(requirements=[_requirement()], source="test") + governor = LifecycleGovernor( + proposal_queue=queue, + outcome_store=JsonPluginOutcomeStore(tmp_path / "o.json"), + lifecycle_actor=_Actor(), + trust_ledger=PluginTrustLedger(), + quarantine_after=3, + ) + verifier = CapabilityEffectVerifier() + requirement = _requirement() + + actions = [] + for _ in range(3): + verdict = verifier.verify( + requirement, {"ok": True, "observed_effect": "nothing happened"}, + plugin_id="gen_useless", + ) + assert verdict.should_record_outcome + result = asyncio.run(governor.record_outcome( + proposal_id=item.proposal_id, plugin_id=verdict.plugin_id, + tool_name="gen_useless", ok=bool(verdict.verified), + )) + actions.append(result.action) + + assert actions[-1] == "quarantine" + assert disabled == ["gen_useless"] # the useless artifact was reclaimed + + +# ── LF-10: the residual case the governor cannot reach ──────────────────────── + + +def _resolution(selected: str = "", exclusions: dict | None = None): + return {"selected_plugin": selected, "exclusions": exclusions or {}} + + +def test_never_selected_risk_excluded_artifact_is_a_candidate(): + reaper = UnselectableArtifactReaper(min_resolutions=3) + resolutions = [ + _resolution("incumbent", {"gen_overrisk": ["risk_cost"]}) + for _ in range(3) + ] + found = reaper.candidates(acquired_plugin_ids=["gen_overrisk"], resolutions=resolutions) + assert [c.plugin_id for c in found] == ["gen_overrisk"] + assert found[0].resolutions_seen == 3 + + +def test_environment_excluded_artifact_is_not_reaped(): + """An environment can change and make it viable again; a risk cap will not.""" + reaper = UnselectableArtifactReaper(min_resolutions=3) + resolutions = [ + _resolution("incumbent", {"gen_v2": ["environment_affordance"]}) + for _ in range(3) + ] + assert reaper.candidates(acquired_plugin_ids=["gen_v2"], resolutions=resolutions) == () + + +def test_a_single_selection_spares_the_artifact(): + reaper = UnselectableArtifactReaper(min_resolutions=3) + resolutions = [ + _resolution("incumbent", {"gen_x": ["risk_cost"]}), + _resolution("gen_x", {}), + _resolution("incumbent", {"gen_x": ["risk_cost"]}), + ] + assert reaper.candidates(acquired_plugin_ids=["gen_x"], resolutions=resolutions) == () + + +def test_too_few_resolutions_condemns_nobody(): + reaper = UnselectableArtifactReaper(min_resolutions=3) + resolutions = [_resolution("incumbent", {"gen_x": ["risk_cost"]})] + assert reaper.candidates(acquired_plugin_ids=["gen_x"], resolutions=resolutions) == () + + +def test_hand_installed_plugins_are_never_reaped(): + reaper = UnselectableArtifactReaper(min_resolutions=1) + resolutions = [_resolution("incumbent", {"hand_made": ["risk_cost"]})] * 3 + assert reaper.candidates(acquired_plugin_ids=[], resolutions=resolutions) == () + + +# ── A-4: quarantine feed, hot path trivial / governance deferred ────────────── + + +def test_streak_only_marks_at_the_threshold(): + tracker = QuarantineCandidateTracker(quarantine_after=3) + assert tracker.record("p", "t", ok=False) is False + assert tracker.record("p", "t", ok=False) is False + assert tracker.record("p", "t", ok=False) is True + assert tracker.pending() == 1 + assert tracker.candidates()[0].failure_streak == 3 + + +def test_success_resets_the_streak_so_intermittent_failures_survive(): + tracker = QuarantineCandidateTracker(quarantine_after=3) + tracker.record("p", "t", ok=False) + tracker.record("p", "t", ok=False) + tracker.record("p", "t", ok=True) # recovered + assert tracker.record("p", "t", ok=False) is False + assert tracker.pending() == 0 + + +def test_tracker_does_no_io_and_ignores_unknown_plugins(): + tracker = QuarantineCandidateTracker(quarantine_after=1) + assert tracker.record("", "t", ok=False) is False # unresolvable tool -> no-op + assert tracker.pending() == 0 + + +def test_drain_governs_each_candidate_then_clears_it(): + calls: list[str] = [] + + class _Governor: + async def record_outcome(self, **kwargs): + calls.append(kwargs["plugin_id"]) + return type("R", (), {"action": "quarantine", "trust_level": "DRAFT"})() + + tracker = QuarantineCandidateTracker(quarantine_after=1) + tracker.record("a", "ta", ok=False) + tracker.record("b", "tb", ok=False) + + handled = asyncio.run(drain_quarantine_candidates(tracker, _Governor())) + assert sorted(h["plugin_id"] for h in handled) == ["a", "b"] + assert sorted(calls) == ["a", "b"] + assert tracker.pending() == 0 + + # A second drain is a no-op, not a double punishment. + assert asyncio.run(drain_quarantine_candidates(tracker, _Governor())) == () + + +def test_one_failing_candidate_does_not_stop_the_others(): + class _Governor: + async def record_outcome(self, **kwargs): + if kwargs["plugin_id"] == "bad": + raise OSError("store down") + return type("R", (), {"action": "quarantine", "trust_level": "DRAFT"})() + + tracker = QuarantineCandidateTracker(quarantine_after=1) + tracker.record("bad", "t1", ok=False) + tracker.record("good", "t2", ok=False) + handled = asyncio.run(drain_quarantine_candidates(tracker, _Governor())) + assert [h["plugin_id"] for h in handled] == ["good"] + assert tracker.pending() == 0 # both cleared regardless + + +# ── P5: only authorised origins may drive acquisition ──────────────────────── + + +def test_unrestricted_by_default(): + for origin in ("unknown_tool", "world_model", "environment_probe", "task_contract"): + assert origin_may_authorise(origin, None) is True + assert origin_may_authorise(origin, ()) is True + + +def test_restricting_to_world_model_is_the_goal_in_executable_form(): + allowed = ("world_model",) + assert origin_may_authorise("world_model", allowed) is True + assert origin_may_authorise("unknown_tool", allowed) is False + assert origin_may_authorise("environment_probe", allowed) is False + + +def test_filter_keeps_only_authorised_requirements(): + wm = EvolutionIntent.create("chat.reply", "gap").to_requirement() + shipped = CapabilityRequirement.create("list_dir", "unknown_tool") + assert len(filter_authorised([wm, shipped], None)) == 2 + kept = filter_authorised([wm, shipped], ("world_model",)) + assert [r.origin for r in kept] == ["world_model"] + + +def test_config_defaults_keep_both_new_switches_off(): + import dataclasses + + from leapflow.config import Settings + + fields = {f.name: f for f in dataclasses.fields(Settings)} + assert fields["evolution_authorising_origins"].default == () + assert fields["accepted_evidence_kinds"].default == () diff --git a/tests/test_observation_lifecycle.py b/tests/test_observation_lifecycle.py new file mode 100644 index 00000000..87e313c5 --- /dev/null +++ b/tests/test_observation_lifecycle.py @@ -0,0 +1,154 @@ +"""LF-11: the observation lifecycle is no longer write-only. + +`JsonCapabilityObservationStore.mark_status` existed with **zero callers and zero +test coverage**, so `unresolved()` grew monotonically: evidence that motivated a +capability which later resolved kept being reported, and any consumer sizing work +from it would re-propose capabilities the system already had. +""" + +from __future__ import annotations + +from leapflow.domain.evolution_intent import WORLD_MODEL_INTENT, EvolutionIntent +from leapflow.learning.capability_observation import ( + CapabilityEvidenceClassifier, + CapabilityObservationService, +) +from leapflow.storage.capability_observation_store import JsonCapabilityObservationStore + +_UNKNOWN = { + "error_type": "unknown_tool", + "original_tool_name": "list_dir", + "recovery_hint": "use file_list", +} + + +def _service(tmp_path, *, kinds=None): + store = JsonCapabilityObservationStore(tmp_path / "observations.json") + classifier = CapabilityEvidenceClassifier.from_kinds(kinds) if kinds else None + return store, CapabilityObservationService(store, classifier=classifier) + + +def test_resolving_a_capability_retires_its_observation(tmp_path): + store, service = _service(tmp_path) + service.observe_result(_UNKNOWN) + assert len(store.unresolved()) == 1 + assert [r.capability for r in service.requirements()] == ["list_dir"] + + retired = service.resolve_capability("list_dir", reason="acquired") + assert len(retired) == 1 + assert store.unresolved() == [] + assert service.requirements() == () # no longer re-proposed + + +def test_resolution_reason_is_recorded(tmp_path): + store, service = _service(tmp_path) + service.observe_result(_UNKNOWN) + service.resolve_capability("list_dir", reason="resolved in loop-7") + record = store.list_observations(limit=10)[0] + assert record["status"] == "resolved" + assert record["status_reason"] == "resolved in loop-7" + + +def test_only_the_matching_capability_is_retired(tmp_path): + store, service = _service(tmp_path) + service.observe_result(_UNKNOWN) + service.observe_result({ + "error_type": "unknown_tool", + "original_tool_name": "grep_code", + }) + assert len(store.unresolved()) == 2 + + service.resolve_capability("list_dir") + remaining = store.unresolved() + assert len(remaining) == 1 + assert remaining[0]["result"]["original_tool_name"] == "grep_code" + + +def test_unrelated_capability_retires_nothing(tmp_path): + store, service = _service(tmp_path) + service.observe_result(_UNKNOWN) + assert service.resolve_capability("something.else") == () + assert len(store.unresolved()) == 1 + + +def test_blank_capability_is_a_no_op(tmp_path): + store, service = _service(tmp_path) + service.observe_result(_UNKNOWN) + assert service.resolve_capability("") == () + assert service.resolve_capability(" ") == () + assert len(store.unresolved()) == 1 + + +def test_world_model_intent_observations_are_retired_too(tmp_path): + """The world-model path must not leak an ever-growing backlog either.""" + store, service = _service(tmp_path, kinds=[WORLD_MODEL_INTENT]) + intent = EvolutionIntent.create("chat.reply", "v2 send path unserved") + service.observe_result(intent.to_observation_result()) + assert len(store.unresolved()) == 1 + + retired = service.resolve_capability("chat.reply") + assert len(retired) == 1 + assert store.unresolved() == [] + + +def test_durable_round_trip_preserves_the_clamped_risk_ceiling(tmp_path): + """The store must not widen a requirement's risk ceiling in transit. + + ``_safe_result`` filters the payload to a field whitelist. When that whitelist + omitted ``max_risk_level``, a requirement rebuilt from a persisted observation + inherited the domain default of ``external`` -- the *most permissive* ceiling -- + so an intent clamped to ``read_only`` came back able to select mutating tools. + ``origin`` was lost the same way, making a world-model intent + indistinguishable from an environment probe. + + This is the production path (engine -> observe_result -> store -> + requirements), so an in-memory-only test cannot cover it. + """ + store, service = _service(tmp_path, kinds=[WORLD_MODEL_INTENT]) + intent = EvolutionIntent.create( + "chat.reply", "v2 send path unserved", + max_risk_level="external", # the model asked for the most permissive + target_affordance="app.chat.v2", + expected_effect="message appears in the thread", + ) + payload = intent.to_observation_result() # ...and was clamped to read_only + assert payload["max_risk_level"] == "read_only" + service.observe_result(payload) + + persisted = store.unresolved()[0]["result"] + assert persisted["max_risk_level"] == "read_only" + assert persisted["origin"] == "world_model" + + requirement = service.requirements()[0] + assert requirement.max_risk_level == "read_only" # not "external" + assert requirement.origin == "world_model" + meta = dict(requirement.metadata) + assert meta["target_affordance"] == "app.chat.v2" + assert meta["requested_max_risk_level"] == "external" # denial stays auditable + + +def test_retiring_is_idempotent(tmp_path): + store, service = _service(tmp_path) + service.observe_result(_UNKNOWN) + first = service.resolve_capability("list_dir") + second = service.resolve_capability("list_dir") + assert len(first) == 1 + assert second == () # already retired, nothing to do + assert len(store.list_observations(limit=10)) == 1 + + +def test_recurrence_after_resolution_is_observed_again(tmp_path): + """A gap that reopens must be visible again, not permanently silenced.""" + store, service = _service(tmp_path) + service.observe_result(_UNKNOWN) + service.resolve_capability("list_dir") + assert store.unresolved() == [] + + # The same failure happens again: dedup updates the existing record, so the + # question is whether a retired observation can come back. + service.observe_result(_UNKNOWN) + reopened = store.unresolved() + assert len(reopened) == 1, ( + "a recurring gap stayed retired; add_observation must reopen a resolved " + "record or a real regression would be silently ignored" + ) diff --git a/tests/test_teacher_capability_validation.py b/tests/test_teacher_capability_validation.py new file mode 100644 index 00000000..3d28f834 --- /dev/null +++ b/tests/test_teacher_capability_validation.py @@ -0,0 +1,146 @@ +"""The teacher's capability names are validated, because a live model abused them. + +S9 ran `qwen3.7-plus` as the teacher against an episode that failed for a +*non-capability* reason. On 3 of 3 trials it returned the episode's own name +(`chat.cosmetic.example`) as the missing capability. Nothing downstream would have +caught it: the string is well-formed, so it would have become a `CapabilityRequirement` +and the governed pipeline would have faithfully tried to build it. + +These pin the two guards that resulted. Neither can catch a *plausible but wrong* +capability -- that is what validation, effect verification and quarantine are for. They +catch the degenerate case, which is the one that produces pure noise. +""" + +from __future__ import annotations + +from leapflow.world_model.trajectory_grader import ( + TrajectoryGrader, + _echoes_goal, + _is_capability_name, +) + + +# ── shape ───────────────────────────────────────────────────────────────────── + + +def test_real_capability_names_are_accepted(): + for name in ("chat.reply", "ui.view_messages", "app.chat.send", "fs.file.read.bytes"): + assert _is_capability_name(name), name + + +def test_prose_and_bare_words_are_rejected(): + """A model asked for a capability sometimes answers with a sentence.""" + for name in ( + "", + "reply", # no dotted structure + "The agent cannot reply to the message", # prose + "chat reply", # spaces + "Chat.Reply", # not lowercase + "chat.", # trailing separator + ".reply", # leading separator + "a.b.c.d.e.f", # too many segments + "chat." + "x" * 90, # too long + "/usr/bin/chat", # a path + ): + assert not _is_capability_name(name), name + + +# ── goal echo ───────────────────────────────────────────────────────────────── + + +def test_the_measured_failure_mode_is_rejected(): + """The exact string the live model returned, 3 of 3 trials.""" + assert _echoes_goal("chat.cosmetic.example", "chat.cosmetic.example") is True + + +def test_echo_detection_ignores_separators_and_case(): + assert _echoes_goal("chat_cosmetic_example", "chat.cosmetic.example") is True + assert _echoes_goal("CHAT.COSMETIC.EXAMPLE", "chat.cosmetic.example") is True + + +def test_a_genuine_capability_is_not_an_echo(): + assert _echoes_goal("chat.reply", "reply to the latest message in the thread") is False + assert _echoes_goal("chat.reply", "") is False + + +# ── the parse path (drive the real method) ──────────────────────────────────── + + +def _grader(): + class _LLM: + async def achat(self, *a, **k): + raise AssertionError("not called") + + class _Store: + def __getattr__(self, name): + return lambda *a, **k: None + + from leapflow.world_model.budget import LearningBudgetController + + return TrajectoryGrader(_LLM(), _Store(), LearningBudgetController(grading_budget=1)) + + +def test_goal_restatement_never_becomes_a_requirement(): + payload = { + "capability_gaps": [ + {"capability": "chat.cosmetic.example", "hypothesis": "the send failed"} + ] + } + assert _grader()._parse_intents(payload, "chat.cosmetic.example") == () + + +def test_a_sentence_never_becomes_a_requirement(): + payload = { + "capability_gaps": [ + {"capability": "the agent lacks a way to reply", "hypothesis": "h"} + ] + } + assert _grader()._parse_intents(payload, "goal") == () + + +def test_a_well_formed_gap_still_passes(): + payload = { + "capability_gaps": [ + { + "capability": "chat.reply", + "hypothesis": "the send control no-ops", + "confidence": 0.8, + "expected_effect": "the reply appears in the thread", + } + ] + } + intents = _grader()._parse_intents(payload, "reply to the latest message") + assert len(intents) == 1 + assert intents[0].capability == "chat.reply" + assert intents[0].expected_effect == "the reply appears in the thread" + + +def test_one_bad_gap_does_not_discard_a_good_one(): + payload = { + "capability_gaps": [ + {"capability": "my.goal", "hypothesis": "h"}, + {"capability": "chat.reply", "hypothesis": "the send control no-ops"}, + ] + } + intents = _grader()._parse_intents(payload, "my.goal") + assert [i.capability for i in intents] == ["chat.reply"] + + +def test_the_prompt_tells_the_model_both_rules(): + """The guard is defence; the prompt is what should prevent it being needed.""" + from leapflow.world_model.trajectory_grader import _GAP_PROMPT_SECTION + + assert "Do NOT restate the task" in _GAP_PROMPT_SECTION + assert "return an empty list" in _GAP_PROMPT_SECTION + assert "worse than" in _GAP_PROMPT_SECTION + + +def test_model_authored_risk_is_still_clamped(): + """The guard must not have disturbed the clamp: a model may never widen risk.""" + payload = { + "capability_gaps": [ + {"capability": "chat.reply", "hypothesis": "h", "max_risk_level": "external"} + ] + } + intents = _grader()._parse_intents(payload, "goal") + assert intents[0].effective_risk_ceiling() == "read_only" diff --git a/tests/test_world_model_driven_evolution_p1.py b/tests/test_world_model_driven_evolution_p1.py new file mode 100644 index 00000000..0a816836 --- /dev/null +++ b/tests/test_world_model_driven_evolution_p1.py @@ -0,0 +1,316 @@ +"""P1: the world model becomes a driver of capability evolution. + +Two halves: + +* **WM-2** -- ``TrajectoryGrader.grade_and_propose`` extends the teacher's single + hindsight call to also emit ``EvolutionIntent``. Proposing must cost no extra + budget token beyond grading, and a malformed proposal must never break grading. +* **WM-4** -- ``accepted_evidence_kinds`` config admits ``world_model_intent`` into + the observation layer, so an intent reaches the governed pipeline. Default + configuration must be unchanged (``unknown_tool`` only). +""" + +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace + +from leapflow.domain.evolution_intent import WORLD_MODEL_INTENT, EvolutionIntent +from leapflow.learning.capability_observation import ( + CapabilityEvidenceClassifier, + CapabilityObservationBuffer, + DEFAULT_ACCEPTED_EVIDENCE, +) +from leapflow.world_model.budget import LearningBudgetController +from leapflow.world_model.trajectory_grader import TeacherVerdict, TrajectoryGrader + +# TrajectoryGrader's min_trajectory_length defaults to 3. +_TRAJECTORY = [ + {"experience_id": "e1", "action_description": "click send_button", + "predicted_effect": "message sent", "actual_effect": "no such element", "delta": "1.0"}, + {"experience_id": "e2", "action_description": "retry click", + "predicted_effect": "message sent", "actual_effect": "no such element", "delta": "1.0"}, + {"experience_id": "e3", "action_description": "scan for alternatives", + "predicted_effect": "found a send control", "actual_effect": "only submit_button", "delta": "0.7"}, +] + + +class _FakeLLM: + """Deterministic teacher response source.""" + + def __init__(self, payload: str) -> None: + self._payload = payload + self.calls = 0 + self.prompts: list[str] = [] + + async def achat(self, messages, **kwargs): + self.calls += 1 + self.prompts.append(str(messages[-1])) + return SimpleNamespace(content=self._payload) + + +class _Store: + """Minimal ExperienceStore stand-in; grading persistence is not under test.""" + + def __init__(self) -> None: + self.updates: list[tuple] = [] + + def update_advantage(self, *args, **kwargs): + self.updates.append((args, kwargs)) + + def __getattr__(self, name): + def _noop(*args, **kwargs): + self.updates.append((name, args, kwargs)) + return _noop + + +def _grader(payload: str, *, grading_budget: int = 5): + llm = _FakeLLM(payload) + budget = LearningBudgetController(grading_budget=grading_budget) + return TrajectoryGrader(llm, _Store(), budget), llm, budget + + +_GRADES = json.dumps({"grades": [ + {"step": 1, "advantage": -0.8, "is_forking": True, "grade_label": "harmful"}, + {"step": 2, "advantage": -0.9, "is_forking": False, "grade_label": "harmful"}, + {"step": 3, "advantage": 0.2, "is_forking": False, "grade_label": "acceptable"}, +]}) + +_GRADES_AND_GAP = json.dumps({ + "grades": [ + {"step": 1, "advantage": -0.8, "is_forking": True, "grade_label": "harmful"}, + {"step": 2, "advantage": -0.9, "is_forking": False, "grade_label": "harmful"}, + {"step": 3, "advantage": 0.2, "is_forking": False, "grade_label": "acceptable"}, + ], + "capability_gaps": [{ + "capability": "chat.reply", + "hypothesis": "the send affordance was renamed and no adapter targets it", + "confidence": 0.82, + "target_affordance": "app.chat.v2", + "rationale": "every available tool binds send_button, which no longer exists", + "expected_effect": "the message appears in the thread", + }], +}) + + +# ── WM-2: the teacher proposes ──────────────────────────────────────────────── + + +def test_grade_and_propose_returns_grades_and_intents(): + grader, llm, _ = _grader(_GRADES_AND_GAP) + verdict = asyncio.run(grader.grade_and_propose(_TRAJECTORY, goal="reply in chat")) + assert isinstance(verdict, TeacherVerdict) + assert len(verdict.grades) == 3 + assert len(verdict.intents) == 1 + intent = verdict.intents[0] + assert isinstance(intent, EvolutionIntent) + assert intent.capability == "chat.reply" + assert intent.target_affordance == "app.chat.v2" + assert 0.8 < intent.confidence < 0.85 + assert llm.calls == 1 # one hindsight call for both outputs + + +def test_proposing_costs_no_extra_budget_token(): + """Grading and proposing must consume exactly one `grading` token.""" + grader, _, budget = _grader(_GRADES_AND_GAP, grading_budget=1) + first = asyncio.run(grader.grade_and_propose(_TRAJECTORY)) + assert first.intents # succeeded on the only token + second = asyncio.run(grader.grade_and_propose(_TRAJECTORY)) + assert second.grades == () and second.intents == () # budget exhausted + assert budget.has_tokens("grading") is False + + +def test_gap_section_only_appears_when_proposing(): + grader, llm, _ = _grader(_GRADES) + asyncio.run(grader.grade_trajectory(_TRAJECTORY)) + assert "capability_gaps" not in llm.prompts[0] + + grader2, llm2, _ = _grader(_GRADES_AND_GAP) + asyncio.run(grader2.grade_and_propose(_TRAJECTORY)) + assert "capability_gaps" in llm2.prompts[0] + + +def test_teacher_is_not_asked_to_choose_a_risk_level(): + """An intent is a hypothesis: the model must not select its own risk ceiling.""" + grader, llm, _ = _grader(_GRADES_AND_GAP) + verdict = asyncio.run(grader.grade_and_propose(_TRAJECTORY)) + assert "risk" not in llm.prompts[0].lower() + # ...and whatever it proposed lands at the clamped ceiling. + assert verdict.intents[0].to_requirement().max_risk_level == "read_only" + + +def test_grading_still_works_when_no_gaps_are_reported(): + grader, _, _ = _grader(_GRADES) + verdict = asyncio.run(grader.grade_and_propose(_TRAJECTORY)) + assert len(verdict.grades) == 3 + assert verdict.intents == () + + +def test_malformed_gaps_are_discarded_without_losing_grades(): + payload = json.dumps({ + "grades": [ + {"step": 1, "advantage": 0.1, "is_forking": False, "grade_label": "acceptable"}, + {"step": 2, "advantage": 0.2, "is_forking": False, "grade_label": "acceptable"}, + {"step": 3, "advantage": 0.3, "is_forking": False, "grade_label": "acceptable"}, + ], + "capability_gaps": [ + {"hypothesis": "no capability field"}, # missing capability + {"capability": "chat.reply"}, # missing hypothesis + "not even an object", + {"capability": "chat.send", "hypothesis": "valid", "confidence": "NaN-ish"}, + ], + }) + grader, _, _ = _grader(payload) + verdict = asyncio.run(grader.grade_and_propose(_TRAJECTORY)) + assert len(verdict.grades) == 3 # grading unaffected + assert [i.capability for i in verdict.intents] == ["chat.send"] + assert verdict.intents[0].confidence == 0.0 # unparseable -> 0.0 + + +def test_unparseable_teacher_response_is_survivable(): + grader, _, _ = _grader("the model rambled instead of emitting JSON") + verdict = asyncio.run(grader.grade_and_propose(_TRAJECTORY)) + assert verdict.grades == () and verdict.intents == () + + +def test_short_trajectory_is_not_graded_or_proposed(): + grader, llm, _ = _grader(_GRADES_AND_GAP) + verdict = asyncio.run(grader.grade_and_propose(_TRAJECTORY[:2])) + assert verdict.grades == () and verdict.intents == () + assert llm.calls == 0 # no LLM spend below the minimum length + + +def test_grade_trajectory_signature_is_unchanged(): + """The pre-existing public API must keep returning a list of grades.""" + grader, _, _ = _grader(_GRADES) + grades = asyncio.run(grader.grade_trajectory(_TRAJECTORY, goal="reply")) + assert isinstance(grades, list) + assert len(grades) == 3 + + +# ── WM-4: config-gated admission into the governed pipeline ─────────────────── + + +def test_default_settings_do_not_admit_world_model_intents(): + settings = SimpleNamespace() # no accepted_evidence_kinds at all + assert CapabilityEvidenceClassifier.from_settings(settings).accepted == DEFAULT_ACCEPTED_EVIDENCE + settings_empty = SimpleNamespace(accepted_evidence_kinds=()) + assert CapabilityEvidenceClassifier.from_settings(settings_empty).accepted == DEFAULT_ACCEPTED_EVIDENCE + + +def test_configured_settings_admit_world_model_intents(): + settings = SimpleNamespace(accepted_evidence_kinds=(WORLD_MODEL_INTENT,)) + classifier = CapabilityEvidenceClassifier.from_settings(settings) + assert classifier.accepted == frozenset({WORLD_MODEL_INTENT}) + intent = EvolutionIntent.create("chat.reply", "v2 send path unserved") + assert classifier.accepts(intent.to_observation_result()) is True + + +def test_teacher_intent_reaches_a_requirement_end_to_end(): + """WM-2 + WM-4 + P0-2: teacher output becomes a governed requirement.""" + grader, _, _ = _grader(_GRADES_AND_GAP) + verdict = asyncio.run(grader.grade_and_propose(_TRAJECTORY, goal="reply in chat")) + settings = SimpleNamespace(accepted_evidence_kinds=(WORLD_MODEL_INTENT,)) + buffer = CapabilityObservationBuffer( + classifier=CapabilityEvidenceClassifier.from_settings(settings) + ) + for intent in verdict.intents: + assert buffer.add_result(intent.to_observation_result()) is True + requirements = buffer.requirements() + assert len(requirements) == 1 + req = requirements[0] + assert req.origin == "world_model" + assert req.capability == "chat.reply" + assert req.max_risk_level == "read_only" # clamped, not model-chosen + assert dict(req.metadata)["target_affordance"] == "app.chat.v2" + + +def test_config_default_is_empty_so_shipped_behaviour_is_unchanged(): + """The new setting must default to opt-out.""" + import dataclasses + + from leapflow.config import Settings + + field = next( + f for f in dataclasses.fields(Settings) if f.name == "accepted_evidence_kinds" + ) + assert field.default == () + + +# ── WM-A: the intent reaches the surface that leads to governed acquisition ─── +# +# The engine's observation hook is documented as observe-only and must stay that +# way. The path that actually leads to acquisition is the self-management tool +# chain: plugin_propose (side-effect-free) -> plugin_generate (validated code, no +# install) -> plugin_install (approval-gated). These tests cover the bridge from a +# world-model intent into that chain's PluginProposal shape. + + +def _intent(**kw): + base = dict( + capability="chat.reply", + hypothesis="the send affordance was renamed and no adapter targets it", + confidence=0.8, + target_affordance="app.chat.v2", + expected_effect="the message appears in the thread", + rationale="every tool binds send_button, which no longer exists", + ) + base.update(kw) + cap = base.pop("capability") + hyp = base.pop("hypothesis") + return EvolutionIntent.create(cap, hyp, **base) + + +def test_intent_becomes_a_plugin_proposal(): + from leapflow.learning.capability_gap_detector import CapabilityGapDetector + + proposal = CapabilityGapDetector().proposal_from_evolution_intent(_intent()) + assert proposal.plugin_id == "chat_reply_plugin" + assert proposal.gap_type == "tool_plugin" + assert proposal.status == "draft" # side-effect-free + assert [t.name for t in proposal.proposed_tools] == ["chat_reply"] + assert proposal.proposed_tools[0].mutates_state is False + + meta = dict(proposal.evidence[0].metadata) + assert proposal.evidence[0].evidence_type == WORLD_MODEL_INTENT + assert meta["target_affordance"] == "app.chat.v2" + assert meta["expected_effect"] == "the message appears in the thread" + assert meta["capability"] == "chat.reply" + + +def test_proposal_risk_is_clamped_not_model_chosen(): + from leapflow.learning.capability_gap_detector import CapabilityGapDetector + + greedy = _intent(max_risk_level="external") + proposal = CapabilityGapDetector().proposal_from_evolution_intent(greedy) + assert proposal.risk_level == "read_only" + assert proposal.proposed_tools[0].risk_level == "read_only" + assert proposal.proposed_tools[0].mutates_state is False + # the denied request stays visible for audit + assert dict(proposal.evidence[0].metadata)["requested_max_risk_level"] == "external" + + +def test_trusted_caller_may_raise_the_proposal_ceiling(): + from leapflow.learning.capability_gap_detector import CapabilityGapDetector + + intent = _intent(max_risk_level="mutating") + proposal = CapabilityGapDetector().proposal_from_evolution_intent( + intent, risk_ceiling="external" + ) + assert proposal.risk_level == "mutating" + assert proposal.proposed_tools[0].mutates_state is True + + +def test_teacher_output_reaches_a_proposal_end_to_end(): + """WM-2 -> WM-A: one hindsight call produces a reviewable proposal.""" + from leapflow.learning.capability_gap_detector import CapabilityGapDetector + + grader, _, _ = _grader(_GRADES_AND_GAP) + verdict = asyncio.run(grader.grade_and_propose(_TRAJECTORY, goal="reply in chat")) + detector = CapabilityGapDetector() + proposals = [detector.proposal_from_evolution_intent(i) for i in verdict.intents] + assert len(proposals) == 1 + assert proposals[0].capability_summary.startswith("the send affordance") + assert proposals[0].risk_level == "read_only" + assert dict(proposals[0].evidence[0].metadata)["target_affordance"] == "app.chat.v2" diff --git a/tests/test_world_model_driver.py b/tests/test_world_model_driver.py new file mode 100644 index 00000000..9500fb27 --- /dev/null +++ b/tests/test_world_model_driver.py @@ -0,0 +1,241 @@ +"""WM-B: the world model is now the first driver of capability evolution. + +`grade_and_propose` could form a capability hypothesis and the observation pipeline +could consume one, but nothing joined them -- so the world model's conclusions +reached no part of the system. `WorldModelEvolutionDriver` is that join. + +The tests that matter most here are the negative ones: the driver must not be able +to bypass the opt-in evidence gate, must not widen a risk ceiling, and must never +fail the session that produced the trajectory. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from leapflow.domain.evolution_intent import WORLD_MODEL_INTENT, EvolutionIntent +from leapflow.learning.capability_observation import ( + CapabilityEvidenceClassifier, + CapabilityObservationService, +) +from leapflow.learning.world_model_driver import ( + CapabilityGapTeacher, + EvidenceIntake, + WorldModelDriveResult, + WorldModelEvolutionDriver, +) +from leapflow.storage.capability_observation_store import JsonCapabilityObservationStore +from leapflow.world_model.trajectory_grader import TeacherVerdict + +_TRAJECTORY = [ + {"experience_id": "e1", "action_description": "click send_button", + "predicted_effect": "sent", "actual_effect": "silently no-op", "delta": "1.0"}, + {"experience_id": "e2", "action_description": "retry", + "predicted_effect": "sent", "actual_effect": "silently no-op", "delta": "1.0"}, +] + + +class _Teacher: + """Stand-in for TrajectoryGrader with a fixed hindsight verdict.""" + + def __init__(self, intents=(), grades=("g1", "g2"), raises=False) -> None: + self._verdict = TeacherVerdict(tuple(grades), tuple(intents)) + self._raises = raises + self.calls = 0 + + async def grade_and_propose(self, trajectory, goal=""): + self.calls += 1 + if self._raises: + raise RuntimeError("teacher exploded") + return self._verdict + + +def _intent(**kw): + base = dict( + confidence=0.8, target_affordance="app.chat.v2", + expected_effect="message appears in the thread", + ) + base.update(kw) + return EvolutionIntent.create("chat.reply", "the send path silently no-ops", **base) + + +def _service(tmp_path, *, opted_in: bool): + store = JsonCapabilityObservationStore(tmp_path / "observations.json") + kinds = [WORLD_MODEL_INTENT] if opted_in else None + classifier = CapabilityEvidenceClassifier.from_kinds(kinds) if kinds else None + return store, CapabilityObservationService(store, classifier=classifier) + + +def _drive(teacher, service, trajectory=_TRAJECTORY, **kw): + driver = WorldModelEvolutionDriver(teacher=teacher, intake=service, **kw) + return asyncio.run(driver.drive(trajectory, "reply in chat")) + + +# ── the join works ──────────────────────────────────────────────────────────── + + +def test_protocols_are_satisfied_by_the_real_components(tmp_path): + _, service = _service(tmp_path, opted_in=True) + assert isinstance(service, EvidenceIntake) + assert isinstance(_Teacher(), CapabilityGapTeacher) + + +def test_teacher_hypothesis_becomes_a_governed_requirement(tmp_path): + """The whole point: hindsight -> intent -> admitted evidence -> requirement.""" + store, service = _service(tmp_path, opted_in=True) + result = _drive(_Teacher(intents=[_intent()]), service) + + assert isinstance(result, WorldModelDriveResult) + assert result.proposed == 1 + assert result.admitted == 1 + assert len(store.unresolved()) == 1 + + requirement = result.requirements[0] + assert requirement.origin == "world_model" # the world model drove this + assert requirement.capability == "chat.reply" + assert requirement.max_risk_level == "read_only" + assert dict(requirement.metadata)["target_affordance"] == "app.chat.v2" + + +def test_grades_are_returned_so_no_second_llm_call_is_needed(tmp_path): + _, service = _service(tmp_path, opted_in=True) + teacher = _Teacher(intents=[_intent()]) + result = _drive(teacher, service) + assert len(result.grades) == 2 + assert teacher.calls == 1 # one hindsight call for grading AND proposing + + +def test_multiple_gaps_all_reach_the_pipeline(tmp_path): + _, service = _service(tmp_path, opted_in=True) + other = EvolutionIntent.create("chat.attach", "no attachment capability exists") + result = _drive(_Teacher(intents=[_intent(), other]), service) + assert result.proposed == 2 and result.admitted == 2 + assert {r.capability for r in result.requirements} == {"chat.reply", "chat.attach"} + + +# ── the driver must not be able to bypass the gate ──────────────────────────── + + +def test_driver_cannot_bypass_the_opt_in_gate(tmp_path): + """Default configuration: proposals are formed but change nothing.""" + store, service = _service(tmp_path, opted_in=False) + result = _drive(_Teacher(intents=[_intent()]), service) + + assert result.proposed == 1 # the world model did form a hypothesis + assert result.admitted == 0 # ...and the gate refused it + assert result.requirements == () + assert store.unresolved() == [] # nothing durable was written + + +def test_driver_cannot_widen_the_risk_ceiling(tmp_path): + _, service = _service(tmp_path, opted_in=True) + greedy = _intent(max_risk_level="external") + result = _drive(_Teacher(intents=[greedy]), service) + requirement = result.requirements[0] + assert requirement.max_risk_level == "read_only" + assert dict(requirement.metadata)["requested_max_risk_level"] == "external" + + +def test_caller_may_tighten_the_ceiling_further(tmp_path): + _, service = _service(tmp_path, opted_in=True) + result = _drive( + _Teacher(intents=[_intent(max_risk_level="medium")]), service, + risk_ceiling="read_only", + ) + assert result.requirements[0].max_risk_level == "read_only" + + +# ── learning must never break the session ───────────────────────────────────── + + +def test_teacher_failure_is_contained(tmp_path): + _, service = _service(tmp_path, opted_in=True) + result = _drive(_Teacher(raises=True), service) + assert result == WorldModelDriveResult() # empty, not an exception + + +def test_intake_failure_is_contained(tmp_path): + class _BrokenIntake: + def observe_result(self, result, **kwargs): + raise OSError("disk on fire") + + def requirements(self, *, min_count=1, limit=50): + return () + + driver = WorldModelEvolutionDriver(teacher=_Teacher(intents=[_intent()]), intake=_BrokenIntake()) + result = asyncio.run(driver.drive(_TRAJECTORY, "goal")) + assert result.proposed == 1 and result.admitted == 0 + + +def test_empty_trajectory_spends_nothing(tmp_path): + _, service = _service(tmp_path, opted_in=True) + teacher = _Teacher(intents=[_intent()]) + result = _drive(teacher, service, trajectory=[]) + assert result == WorldModelDriveResult() + assert teacher.calls == 0 # no LLM spend without an episode + + +def test_no_gaps_still_returns_grades(tmp_path): + _, service = _service(tmp_path, opted_in=True) + result = _drive(_Teacher(intents=[]), service) + assert len(result.grades) == 2 + assert result.proposed == 0 and result.requirements == () + + +def test_drive_result_is_reportable(tmp_path): + _, service = _service(tmp_path, opted_in=True) + payload = _drive(_Teacher(intents=[_intent()]), service).to_dict() + assert payload["proposed"] == 1 + assert payload["admitted"] == 1 + assert payload["capabilities"] == ["chat.reply"] + + +# ── the real grader satisfies the teacher contract ──────────────────────────── + + +def test_real_trajectory_grader_can_drive_evolution(tmp_path): + """End to end with the REAL TrajectoryGrader, only the LLM substituted.""" + import json + + from leapflow.world_model.budget import LearningBudgetController + from leapflow.world_model.trajectory_grader import TrajectoryGrader + + payload = json.dumps({ + "grades": [ + {"step": 1, "advantage": -0.9, "is_forking": True, "grade_label": "harmful"}, + {"step": 2, "advantage": -0.9, "is_forking": False, "grade_label": "harmful"}, + {"step": 3, "advantage": -0.5, "is_forking": False, "grade_label": "suboptimal"}, + ], + "capability_gaps": [{ + "capability": "chat.reply", + "hypothesis": "the send control exists but no longer delivers the message", + "confidence": 0.77, + "target_affordance": "app.chat.v2", + "expected_effect": "the message appears in the thread", + }], + }) + + class _FakeLLM: + async def achat(self, messages, **kwargs): + return SimpleNamespace(content=payload) + + class _Store: + def __getattr__(self, name): + return lambda *a, **k: None + + grader = TrajectoryGrader(_FakeLLM(), _Store(), LearningBudgetController(grading_budget=2)) + assert isinstance(grader, CapabilityGapTeacher) + + _, service = _service(tmp_path, opted_in=True) + trajectory = _TRAJECTORY + [ + {"experience_id": "e3", "action_description": "verify thread", + "predicted_effect": "message present", "actual_effect": "absent", "delta": "1.0"}, + ] + driver = WorldModelEvolutionDriver(teacher=grader, intake=service) + result = asyncio.run(driver.drive(trajectory, "reply in chat")) + + assert result.proposed == 1 + assert result.admitted == 1 + assert result.requirements[0].origin == "world_model" + assert result.requirements[0].capability == "chat.reply" diff --git a/tests/test_world_model_evolution_p0.py b/tests/test_world_model_evolution_p0.py new file mode 100644 index 00000000..40da4366 --- /dev/null +++ b/tests/test_world_model_evolution_p0.py @@ -0,0 +1,307 @@ +"""World-model-first evolution foundation (P0). + +Covers the four P0 changes: + +* ``RequirementOrigin`` accepts ``world_model``. +* ``CapabilityGapDetector`` turns *declared* non-unknown-tool evidence into + requirements. Before this, ``CapabilityEvidenceClassifier`` could admit an + evidence kind into the durable store while ``requirements()`` silently dropped + it -- a half-wired seam. +* ``EvolutionIntent`` is the world model's proposal contract and travels the + existing observation path. +* ``PluginTrustLedger.is_frozen`` + ``FrozenExclusionScorer`` make a frozen + plugin ineligible at selection, independently of governance. +""" + +from __future__ import annotations + +from leapflow.domain.capability_requirement import CapabilityRequirement +from leapflow.domain.evolution_intent import ( + WORLD_MODEL_INTENT, + WORLD_MODEL_ORIGIN, + EvolutionIntent, +) +from leapflow.learning.capability_gap_detector import CapabilityGapDetector +from leapflow.learning.capability_observation import ( + CapabilityEvidenceClassifier, + CapabilityObservationBuffer, +) +from leapflow.learning.plugin_trust import PluginTrustLedger, PluginTrustLevel + +_UNKNOWN = { + "error_type": "unknown_tool", + "original_tool_name": "list_dir", + "suggestions": ["file_list"], + "recovery_hint": "use file_list", +} + + +# ── the unknown_tool path must be unchanged ─────────────────────────────────── + + +def test_unknown_tool_requirement_is_unchanged(): + reqs = CapabilityGapDetector().requirements_from_tool_results([_UNKNOWN]) + assert len(reqs) == 1 + req = reqs[0] + assert req.capability == "list_dir" + assert req.origin == "unknown_tool" + assert req.requirement_id == "req-unknown-tool-list_dir" + assert req.evidence == "Runtime attempted unknown tool 'list_dir'." + assert dict(req.metadata)["original_tool_name"] == "list_dir" + assert dict(req.metadata)["occurrences"] == "1" + + +def test_min_count_still_filters_unknown_tool(): + detector = CapabilityGapDetector() + assert detector.requirements_from_tool_results([_UNKNOWN], min_count=2) == () + assert len(detector.requirements_from_tool_results([_UNKNOWN, _UNKNOWN], min_count=2)) == 1 + + +# ── declared evidence now produces requirements (the defect fix) ────────────── + + +def test_declared_evidence_becomes_a_requirement(): + result = { + "error_type": "interface_drift", + "capability": "chat.reply", + "origin": "environment_probe", + "recovery_hint": "send_button is gone", + "failure_code": "interface_names_missing", + "max_risk_level": "read_only", + } + reqs = CapabilityGapDetector().requirements_from_tool_results([result]) + assert len(reqs) == 1 + req = reqs[0] + assert req.capability == "chat.reply" + assert req.origin == "environment_probe" + assert req.max_risk_level == "read_only" + assert req.evidence == "send_button is gone" + meta = dict(req.metadata) + assert meta["evidence_kind"] == "interface_drift" + assert meta["failure_code"] == "interface_names_missing" + + +def test_declared_evidence_without_capability_is_ignored(): + """Never infer a capability from text: no declaration, no requirement.""" + result = {"error_type": "interface_drift", "recovery_hint": "something changed"} + assert CapabilityGapDetector().requirements_from_tool_results([result]) == () + + +def test_unrecognised_origin_falls_back_and_cannot_be_smuggled(): + result = { + "error_type": "interface_drift", + "capability": "chat.reply", + "origin": "totally_made_up", + } + reqs = CapabilityGapDetector().requirements_from_tool_results([result]) + assert reqs[0].origin == "environment_probe" + + +def test_declared_evidence_buckets_by_kind_and_capability(): + a = {"error_type": "interface_drift", "capability": "chat.reply"} + b = {"error_type": "affordance_removed", "capability": "chat.reply"} + c = {"error_type": "interface_drift", "capability": "chat.send"} + reqs = CapabilityGapDetector().requirements_from_tool_results([a, b, c, a]) + # 3 distinct (kind, capability) pairs; the repeat merges into its bucket. + assert len(reqs) == 3 + drift_reply = [r for r in reqs if dict(r.metadata)["evidence_kind"] == "interface_drift" + and r.capability == "chat.reply"] + assert dict(drift_reply[0].metadata)["occurrences"] == "2" + + +def test_mixed_evidence_yields_both_kinds(): + declared = {"error_type": "interface_drift", "capability": "chat.reply"} + reqs = CapabilityGapDetector().requirements_from_tool_results([_UNKNOWN, declared]) + origins = {r.origin for r in reqs} + assert origins == {"unknown_tool", "environment_probe"} + + +# ── EvolutionIntent ─────────────────────────────────────────────────────────── + + +def test_intent_defaults_to_the_lowest_risk_ceiling(): + """A model-authored proposal must not inherit the permissive domain default.""" + intent = EvolutionIntent.create("chat.reply", "v3 dispatch path is unserved") + assert intent.max_risk_level == "read_only" + assert CapabilityRequirement.create("x", "world_model").max_risk_level == "external" + + +def test_intent_requires_capability_and_hypothesis(): + for bad in [("", "h"), ("c", "")]: + try: + EvolutionIntent.create(*bad) + except ValueError: + continue + raise AssertionError(f"expected ValueError for {bad!r}") + + +def test_intent_confidence_is_clamped(): + assert EvolutionIntent.create("c", "h", confidence=5.0).confidence == 1.0 + assert EvolutionIntent.create("c", "h", confidence=-2.0).confidence == 0.0 + + +def test_intent_to_requirement_carries_world_model_origin(): + intent = EvolutionIntent.create( + "chat.reply", "the v3 dispatch button is unserved", + confidence=0.7, target_affordance="app.chat.v3", + expected_effect="message appears in thread", evidence_ids=["exp-1", "exp-2"], + ) + req = intent.to_requirement() + assert req.origin == WORLD_MODEL_ORIGIN == "world_model" + assert req.capability == "chat.reply" + assert req.requirement_id == f"req-wm-{intent.intent_id}" + meta = dict(req.metadata) + assert meta["evidence_kind"] == WORLD_MODEL_INTENT + assert meta["target_affordance"] == "app.chat.v3" + assert meta["evidence_ids"] == "exp-1,exp-2" + + +def test_intent_flows_through_the_governed_observation_path(): + """The whole point: an intent is governed by the same machinery, not a new one.""" + intent = EvolutionIntent.create("chat.reply", "v3 unserved", confidence=0.6) + result = intent.to_observation_result() + + # Default classifier must NOT admit it (shipped behaviour unchanged). + assert CapabilityObservationBuffer().add_result(result) is False + + # Opt in, and it reaches the buffer and becomes a requirement. + buffer = CapabilityObservationBuffer( + classifier=CapabilityEvidenceClassifier.from_kinds([WORLD_MODEL_INTENT]) + ) + assert buffer.add_result(result) is True + reqs = buffer.requirements() + assert len(reqs) == 1 + assert reqs[0].origin == "world_model" + assert reqs[0].capability == "chat.reply" + assert reqs[0].max_risk_level == "read_only" + + +def test_intent_round_trips_through_dict(): + intent = EvolutionIntent.create( + "chat.reply", "h", confidence=0.5, target_affordance="app.chat.v3", + evidence_ids=["e1"], required_platform_capabilities=["ui.automation"], + ) + restored = EvolutionIntent.from_dict(intent.to_dict()) + assert restored.to_dict() == intent.to_dict() + + +# ── the risk ceiling is an authorisation, so an intent may only narrow it ────── + + +def test_intent_cannot_widen_its_own_risk_ceiling(): + """A model-authored intent asking for `external` must not get it. + + `max_risk_level` is a ceiling: a larger value permits selecting riskier + tools. Letting the authoring model choose it would make the intent an + authorisation rather than a hypothesis. + """ + greedy = EvolutionIntent.create("chat.reply", "h", max_risk_level="external") + assert greedy.max_risk_level == "external" # the request is preserved... + req = greedy.to_requirement() # ...but not granted + assert req.max_risk_level == "read_only" + assert dict(req.metadata)["requested_max_risk_level"] == "external" + assert greedy.to_observation_result()["max_risk_level"] == "read_only" + + +def test_intent_may_narrow_below_the_caller_ceiling(): + intent = EvolutionIntent.create("chat.reply", "h", max_risk_level="read_only") + req = intent.to_requirement(risk_ceiling="external") + assert req.max_risk_level == "read_only" # stricter of the two wins + assert "requested_max_risk_level" not in dict(req.metadata) + + +def test_trusted_caller_may_raise_the_ceiling_deliberately(): + intent = EvolutionIntent.create("chat.reply", "h", max_risk_level="medium") + assert intent.to_requirement(risk_ceiling="high").max_risk_level == "medium" + assert intent.to_requirement(risk_ceiling="read_only").max_risk_level == "read_only" + + +def test_unknown_risk_level_is_treated_as_most_permissive_and_clamped(): + """A typo must not read as safe and slip past the clamp.""" + intent = EvolutionIntent.create("chat.reply", "h", max_risk_level="totally_safe_promise") + assert intent.to_requirement().max_risk_level == "read_only" + + +def test_clamped_intent_still_flows_through_the_governed_path(): + greedy = EvolutionIntent.create("chat.reply", "h", max_risk_level="external") + buffer = CapabilityObservationBuffer( + classifier=CapabilityEvidenceClassifier.from_kinds([WORLD_MODEL_INTENT]) + ) + assert buffer.add_result(greedy.to_observation_result()) is True + reqs = buffer.requirements() + # The requirement that reaches resolution carries the clamped ceiling. + assert reqs[0].max_risk_level == "read_only" + + +# ── LF-9: frozen plugins must be ineligible, not merely low-scored ──────────── + + +def test_is_frozen_distinguishes_frozen_from_merely_draft(): + ledger = PluginTrustLedger() + assert ledger.is_frozen("fresh") is False + assert ledger.level("fresh") == PluginTrustLevel.DRAFT # DRAFT but not frozen + ledger.record_failure("broken", hard=True) + assert ledger.is_frozen("broken") is True + assert ledger.level("broken") == PluginTrustLevel.DRAFT # same level, different meaning + + +def test_frozen_survives_ledger_serialization(): + ledger = PluginTrustLedger() + ledger.record_failure("broken", hard=True) + restored = PluginTrustLedger.load_state(ledger.to_state()) + assert restored.is_frozen("broken") is True + + +def test_frozen_exclusion_scorer_excludes_only_frozen(): + from leapflow.plugins.capability_resolver import ( + CapabilityCandidate, + FrozenExclusionScorer, + ResolverContext, + ) + from leapflow.domain.environment_fingerprint import EnvironmentFingerprint + + ledger = PluginTrustLedger() + ledger.record_failure("broken", hard=True) + context = ResolverContext( + environment=EnvironmentFingerprint( + platform_id="linux_gnome", os_version="x", platform_capabilities=(), workspace_root="/w" + ), + trust_ledger=ledger, + ) + req = CapabilityRequirement.create("chat.reply", "task_contract") + scorer = FrozenExclusionScorer() + + frozen = CapabilityCandidate(plugin_id="broken", tool_name="broken_tool") + healthy = CapabilityCandidate(plugin_id="healthy", tool_name="healthy_tool") + assert scorer.score(req, frozen, context).excluded is True + assert scorer.score(req, healthy, context).excluded is False + + +def test_frozen_exclusion_scorer_is_inert_without_a_ledger(): + """Absent trust information must not exclude everything.""" + from leapflow.plugins.capability_resolver import ( + CapabilityCandidate, + FrozenExclusionScorer, + ResolverContext, + ) + from leapflow.domain.environment_fingerprint import EnvironmentFingerprint + + context = ResolverContext( + environment=EnvironmentFingerprint( + platform_id="linux_gnome", os_version="x", platform_capabilities=(), workspace_root="/w" + ) + ) + req = CapabilityRequirement.create("chat.reply", "task_contract") + component = FrozenExclusionScorer().score( + req, CapabilityCandidate(plugin_id="p", tool_name="t"), context + ) + assert component.excluded is False + + +def test_frozen_exclusion_is_not_a_default_scorer(): + """Default resolution must be unchanged by this addition.""" + from leapflow.plugins import capability_resolver as cr + + names = {type(s).__name__ for s in cr._DEFAULT_SCORERS} + assert "FrozenExclusionScorer" not in names + assert "EnvironmentAffordanceScorer" not in names # same opt-in contract From 285d74885cb3fb7963e014bbd09924fab1513664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 15 Sep 2026 14:20:03 +0800 Subject: [PATCH 4/6] feat: teacher/student world-model evolution with gated self-evolution Two-agent split: the world model (cold path) grades each session and distils what it learned into the student's next-turn context. It answers four-value adaptation verdicts (absorb / rebind / acquire / escalate), each carrying knowledge for the acting agent. - C1 distilled-knowledge channel with supersede/expire/retract retirement - C2 rebind recommendation as a resolver preference (never a gate) - C3 acquire -> queued plugin proposal, behind the evolution.enabled switch - degradation feedback loop wires plugin health into capability evidence; recovery retires stale knowledge - evolution.enabled switch (default off), own config category, announced at startup in both states - fix: lifecycle governor / durable trust ledger / proposal sink were never wired in production; one clamp point for model-authored risk - remove the unused Thompson/UCB1/Bucketed selection policies; keep the seam - inert-wiring audit tool; repository-wide copyright headers Verified: 3733 mock tests, journeys/regression/leapspace green, real-LLM S9 teacher accuracy with four-action discrimination. --- .../plugins/third_party_plugin_development.md | 84 +- src/leapflow/__init__.py | 1 + src/leapflow/__main__.py | 1 + src/leapflow/analysis/__init__.py | 1 + src/leapflow/analysis/abstractor.py | 1 + src/leapflow/analysis/causal.py | 1 + src/leapflow/analysis/consensus.py | 1 + src/leapflow/analysis/denoise.py | 1 + src/leapflow/analysis/environment_catalog.py | 1 + src/leapflow/analysis/environment_probe.py | 1 + src/leapflow/analysis/episode_dedup.py | 1 + src/leapflow/analysis/fs_pattern_pass.py | 1 + src/leapflow/analysis/intent_inferrer.py | 1 + src/leapflow/analysis/patterns.py | 1 + src/leapflow/analysis/pipeline.py | 1 + src/leapflow/analysis/segmenter.py | 1 + src/leapflow/analysis/synthesis.py | 1 + src/leapflow/cache/__init__.py | 1 + src/leapflow/cache/manager.py | 1 + src/leapflow/causal/__init__.py | 1 + src/leapflow/causal/adapter.py | 1 + src/leapflow/causal/channel.py | 1 + src/leapflow/causal/components.py | 1 + src/leapflow/causal/inference.py | 1 + src/leapflow/causal/pipeline.py | 1 + src/leapflow/causal/types.py | 1 + src/leapflow/cli/__init__.py | 1 + src/leapflow/cli/approval_view.py | 1 + src/leapflow/cli/banner.py | 1 + src/leapflow/cli/cli.py | 1 + src/leapflow/cli/commands/__init__.py | 1 + src/leapflow/cli/commands/chat.py | 1 + src/leapflow/cli/commands/config.py | 1 + src/leapflow/cli/commands/daemon.py | 1 + src/leapflow/cli/commands/dashboard.py | 1 + src/leapflow/cli/commands/hardware.py | 1 + src/leapflow/cli/commands/host.py | 1 + src/leapflow/cli/commands/hub.py | 1 + src/leapflow/cli/commands/interactive.py | 29 + src/leapflow/cli/commands/registry.py | 1 + src/leapflow/cli/commands/relearn.py | 1 + src/leapflow/cli/commands/router.py | 1 + src/leapflow/cli/commands/run.py | 1 + src/leapflow/cli/commands/scheduler.py | 1 + src/leapflow/cli/commands/skills.py | 1 + src/leapflow/cli/commands/slash_handlers.py | 1 + src/leapflow/cli/commands/teach.py | 1 + src/leapflow/cli/context.py | 222 ++++- src/leapflow/cli/helpers.py | 1 + src/leapflow/cli/tui.py | 1 + src/leapflow/cli/tui_app/__init__.py | 1 + src/leapflow/cli/tui_app/app.py | 1 + src/leapflow/cli/tui_app/approval_modal.py | 1 + src/leapflow/cli/tui_app/command.py | 1 + src/leapflow/cli/tui_app/console.py | 16 +- src/leapflow/cli/tui_app/input.py | 1 + src/leapflow/cli/tui_app/paste.py | 1 + src/leapflow/cli/tui_app/session_summary.py | 1 + src/leapflow/cli/tui_app/status.py | 1 + src/leapflow/cli/tui_app/stream.py | 1 + src/leapflow/cli/tui_app/theme.py | 1 + src/leapflow/config.py | 48 + src/leapflow/config_loader.py | 1 + src/leapflow/config_service.py | 50 +- src/leapflow/copilot/__init__.py | 1 + src/leapflow/copilot/adapters.py | 1 + src/leapflow/copilot/config.py | 1 + src/leapflow/copilot/context.py | 1 + src/leapflow/copilot/degradation.py | 1 + src/leapflow/copilot/engine.py | 1 + src/leapflow/copilot/feedback.py | 1 + src/leapflow/copilot/idle.py | 1 + src/leapflow/copilot/os_renderer.py | 1 + src/leapflow/copilot/pipeline.py | 1 + src/leapflow/copilot/predictors/__init__.py | 1 + src/leapflow/copilot/predictors/l0_hash.py | 1 + src/leapflow/copilot/predictors/l1_markov.py | 1 + src/leapflow/copilot/predictors/l2_embed.py | 1 + src/leapflow/copilot/predictors/l3_llm.py | 1 + src/leapflow/copilot/renderer.py | 1 + src/leapflow/copilot/types.py | 1 + src/leapflow/daemon/__init__.py | 1 + src/leapflow/daemon/_service_helpers.py | 1 + src/leapflow/daemon/_transport.py | 1 + src/leapflow/daemon/approval_coordinator.py | 1 + src/leapflow/daemon/approval_route.py | 1 + src/leapflow/daemon/client.py | 1 + src/leapflow/daemon/lease.py | 1 + src/leapflow/daemon/lifecycle.py | 1 + src/leapflow/daemon/monitor_coordinator.py | 1 + src/leapflow/daemon/notifications.py | 1 + src/leapflow/daemon/protocol.py | 1 + src/leapflow/daemon/reentry_coordinator.py | 1 + src/leapflow/daemon/server.py | 1 + src/leapflow/daemon/service.py | 1 + src/leapflow/daemon/session_coordinator.py | 1 + src/leapflow/daemon/session_registry.py | 1 + src/leapflow/daemon/turn_admission.py | 1 + src/leapflow/dashboard/__init__.py | 1 + src/leapflow/dashboard/hub.py | 1 + src/leapflow/dashboard/intent.py | 1 + src/leapflow/dashboard/launcher.py | 1 + src/leapflow/dashboard/revision.py | 1 + src/leapflow/dashboard/server.py | 1 + src/leapflow/dashboard/service.py | 1 + src/leapflow/dashboard/static/app.js | 10 +- src/leapflow/dashboard/templates.py | 1 + .../dashboard/templates/evolution.yaml | 44 + src/leapflow/dashboard/viewspec.py | 1 + src/leapflow/domain/__init__.py | 1 + src/leapflow/domain/adaptation_verdict.py | 203 +++++ src/leapflow/domain/capability_requirement.py | 1 + src/leapflow/domain/effect_scope.py | 1 + .../domain/environment_fingerprint.py | 1 + src/leapflow/domain/event_types.py | 1 + src/leapflow/domain/events.py | 1 + src/leapflow/domain/evolution_intent.py | 19 + src/leapflow/domain/evolution_trace.py | 1 + src/leapflow/domain/perception.py | 1 + src/leapflow/domain/platform.py | 1 + src/leapflow/domain/plugin_fiber.py | 1 + src/leapflow/domain/plugin_proposal.py | 1 + src/leapflow/domain/skill_types.py | 1 + src/leapflow/domain/tool_pipeline.py | 1 + src/leapflow/domain/trajectory.py | 1 + src/leapflow/domain/ui_vocabulary.py | 1 + src/leapflow/engine/__init__.py | 1 + src/leapflow/engine/agent_loop.py | 1 + src/leapflow/engine/audit.py | 1 + src/leapflow/engine/budget.py | 1 + src/leapflow/engine/confirmation.py | 1 + src/leapflow/engine/context_compressor.py | 1 + src/leapflow/engine/context_control.py | 1 + src/leapflow/engine/context_disclosure.py | 1 + src/leapflow/engine/context_focus.py | 1 + src/leapflow/engine/engine.py | 165 +++- src/leapflow/engine/error_classifier.py | 1 + src/leapflow/engine/execution_trace.py | 1 + src/leapflow/engine/failure_envelope.py | 1 + src/leapflow/engine/graph_planner.py | 1 + src/leapflow/engine/intent_classifier.py | 1 + src/leapflow/engine/interaction_request.py | 1 + src/leapflow/engine/message_healer.py | 1 + src/leapflow/engine/message_sanitizer.py | 1 + src/leapflow/engine/oneshot_guard.py | 1 + src/leapflow/engine/pipeline_observer.py | 1 + src/leapflow/engine/planner.py | 1 + src/leapflow/engine/prefix_commitment.py | 1 + src/leapflow/engine/prompt_cache.py | 1 + src/leapflow/engine/recovery_audit.py | 1 + src/leapflow/engine/recovery_budget.py | 1 + src/leapflow/engine/recovery_checkpoint.py | 1 + src/leapflow/engine/recovery_coordinator.py | 1 + src/leapflow/engine/recovery_decision.py | 1 + .../engine/recovery_strategies/__init__.py | 1 + .../recovery_strategies/context_compress.py | 1 + .../recovery_strategies/credential_rotate.py | 1 + .../recovery_strategies/jittered_retry.py | 1 + .../recovery_strategies/multimodal_strip.py | 1 + .../recovery_strategies/native_to_text.py | 1 + .../recovery_strategies/provider_failover.py | 1 + .../recovery_strategies/thinking_disable.py | 1 + .../recovery_strategies/tool_schema_expand.py | 1 + src/leapflow/engine/reference_resolver.py | 1 + src/leapflow/engine/research_ledger.py | 1 + src/leapflow/engine/resilience.py | 1 + src/leapflow/engine/scheduler.py | 1 + src/leapflow/engine/session.py | 1 + src/leapflow/engine/session_factory.py | 1 + src/leapflow/engine/situational_assessor.py | 1 + src/leapflow/engine/stale_stream.py | 1 + src/leapflow/engine/subagent.py | 1 + src/leapflow/engine/task_graph.py | 1 + src/leapflow/engine/terminal_io.py | 1 + src/leapflow/engine/tool_concurrency.py | 1 + src/leapflow/engine/tool_execution.py | 1 + src/leapflow/engine/tool_guardrails.py | 1 + src/leapflow/engine/turn_recovery.py | 1 + src/leapflow/engine/turn_usage.py | 1 + src/leapflow/engine/unified_classifier.py | 1 + src/leapflow/evolution/__init__.py | 1 + src/leapflow/evolution/ledger.py | 1 + src/leapflow/evolution/observations.py | 1 + src/leapflow/evolution/sink.py | 1 + src/leapflow/evolution/sweep.py | 44 + src/leapflow/gateway/__init__.py | 1 + src/leapflow/gateway/action_packs/__init__.py | 1 + src/leapflow/gateway/action_packs/feishu.py | 1 + src/leapflow/gateway/adapter_registry.py | 1 + src/leapflow/gateway/adapters/__init__.py | 1 + src/leapflow/gateway/adapters/api_server.py | 1 + src/leapflow/gateway/adapters/common.py | 1 + src/leapflow/gateway/adapters/dingtalk.py | 1 + src/leapflow/gateway/adapters/feishu.py | 1 + src/leapflow/gateway/adapters/telegram.py | 1 + src/leapflow/gateway/adapters/webhook.py | 1 + src/leapflow/gateway/backends/__init__.py | 1 + src/leapflow/gateway/backends/cli_backend.py | 1 + .../gateway/backends/lark_cli_errors.py | 1 + src/leapflow/gateway/backends/rest_backend.py | 1 + src/leapflow/gateway/capability_health.py | 1 + src/leapflow/gateway/checkpoint_store.py | 1 + src/leapflow/gateway/config_store.py | 1 + src/leapflow/gateway/connectors/__init__.py | 1 + .../gateway/connectors/action_registry.py | 1 + .../gateway/connectors/cli_discovery.py | 1 + .../connectors/composite_event_source.py | 1 + .../connectors/dingtalk_event_source.py | 1 + .../gateway/connectors/event_sources.py | 1 + .../gateway/connectors/lark_event_source.py | 1 + src/leapflow/gateway/connectors/protocol.py | 1 + .../connectors/telegram_event_source.py | 1 + src/leapflow/gateway/credential_vault.py | 1 + src/leapflow/gateway/event_bridge.py | 1 + src/leapflow/gateway/events.py | 1 + src/leapflow/gateway/manifest.py | 1 + src/leapflow/gateway/mixin.py | 1 + src/leapflow/gateway/normalizers/__init__.py | 1 + src/leapflow/gateway/normalizers/dingtalk.py | 1 + src/leapflow/gateway/normalizers/feishu.py | 1 + src/leapflow/gateway/normalizers/telegram.py | 1 + src/leapflow/gateway/protocol.py | 1 + src/leapflow/gateway/resource_provenance.py | 1 + src/leapflow/gateway/router.py | 1 + .../gateway/scoped_adapter_registry.py | 1 + src/leapflow/gateway/server.py | 1 + src/leapflow/gateway/session_router.py | 1 + src/leapflow/gateway/trigger_policy.py | 1 + src/leapflow/gateway/validators/__init__.py | 1 + src/leapflow/gateway/validators/_http.py | 1 + src/leapflow/gateway/validators/dingtalk.py | 1 + src/leapflow/gateway/validators/telegram.py | 1 + src/leapflow/hardware/__init__.py | 1 + src/leapflow/hardware/alert_policy.py | 1 + src/leapflow/hardware/audit.py | 1 + src/leapflow/hardware/calibration_store.py | 1 + src/leapflow/hardware/context.py | 1 + src/leapflow/hardware/host_metrics.py | 1 + src/leapflow/hardware/media.py | 1 + .../hardware/observability/__init__.py | 1 + src/leapflow/hardware/observability/digest.py | 1 + .../hardware/observability/exporter.py | 1 + .../hardware/observability/inventory.py | 1 + .../hardware/observability/producer.py | 1 + src/leapflow/hardware/observability/series.py | 1 + src/leapflow/hardware/outcome.py | 1 + src/leapflow/hardware/plugin.py | 1 + src/leapflow/hardware/preview.py | 1 + src/leapflow/hardware/providers/__init__.py | 1 + .../hardware/providers/host_provider.py | 1 + .../hardware/providers/media_provider.py | 1 + .../hardware/providers/yaml_provider.py | 1 + src/leapflow/hardware/reading_store.py | 1 + src/leapflow/hardware/reference.py | 1 + src/leapflow/hardware/registry.py | 1 + src/leapflow/hardware/replay.py | 1 + src/leapflow/hardware/risk.py | 1 + src/leapflow/hardware/stream.py | 1 + src/leapflow/hardware/testing.py | 1 + src/leapflow/hardware/tools.py | 1 + src/leapflow/hardware/transport.py | 1 + src/leapflow/hardware/transports/__init__.py | 1 + src/leapflow/hardware/transports/host.py | 1 + src/leapflow/hardware/transports/mcp.py | 1 + src/leapflow/hardware/transports/media.py | 1 + src/leapflow/hardware/transports/mock.py | 1 + .../hardware/transports/python_callable.py | 1 + src/leapflow/hardware/transports/simulated.py | 1 + src/leapflow/hardware/trust.py | 1 + src/leapflow/hub/__init__.py | 1 + src/leapflow/hub/backends/__init__.py | 1 + src/leapflow/hub/backends/github.py | 1 + src/leapflow/hub/backends/huggingface.py | 1 + src/leapflow/hub/backends/local.py | 1 + src/leapflow/hub/backends/modelscope.py | 1 + src/leapflow/hub/client.py | 1 + src/leapflow/hub/protocol.py | 1 + src/leapflow/hub/security.py | 1 + src/leapflow/hub/serializer.py | 1 + src/leapflow/hub/sync.py | 1 + src/leapflow/layout.py | 9 + src/leapflow/learning/__init__.py | 1 + src/leapflow/learning/active_learning.py | 1 + .../learning/capability_effect_verifier.py | 50 +- .../learning/capability_gap_detector.py | 29 +- .../learning/capability_observation.py | 115 ++- src/leapflow/learning/codegen.py | 1 + src/leapflow/learning/cold_start.py | 1 + .../learning/compatibility/__init__.py | 1 + .../compatibility/adapter_generator.py | 1 + .../compatibility/manifest_converter.py | 1 + .../learning/compatibility/pipeline.py | 1 + .../learning/compatibility/protocol.py | 1 + .../compatibility/source_inspector.py | 1 + .../learning/compatibility/stages/__init__.py | 1 + .../compatibility/stages/category_resolver.py | 1 + .../stages/dependency_checker.py | 1 + .../compatibility/stages/execution_model.py | 1 + .../stages/interface_analyzer.py | 1 + .../compatibility/stages/manifest_parser.py | 1 + .../stages/security_classifier.py | 1 + .../learning/compatibility/taxonomy.py | 1 + .../learning/compatibility/verdict.py | 1 + src/leapflow/learning/degradation_sink.py | 282 ++++++ .../learning/difficulty_calibration.py | 1 + src/leapflow/learning/distiller.py | 1 + src/leapflow/learning/doc_generator.py | 1 + src/leapflow/learning/document.py | 1 + src/leapflow/learning/effectiveness.py | 1 + src/leapflow/learning/event_consumer.py | 1 + src/leapflow/learning/feedback.py | 1 + src/leapflow/learning/learnability.py | 1 + .../learning/outcome_governance_feed.py | 1 + src/leapflow/learning/pattern_miner.py | 1 + src/leapflow/learning/plugin_advisor.py | 1 + .../learning/plugin_behavior_tests.py | 1 + src/leapflow/learning/plugin_generator.py | 26 +- src/leapflow/learning/plugin_stats.py | 1 + src/leapflow/learning/plugin_stats_store.py | 1 + src/leapflow/learning/plugin_trust.py | 1 + src/leapflow/learning/similarity.py | 1 + src/leapflow/learning/stream_progress.py | 1 + src/leapflow/learning/world_model_driver.py | 265 +++++- src/leapflow/llm/__init__.py | 1 + src/leapflow/llm/_builtin_plugins.py | 1 + src/leapflow/llm/base.py | 1 + src/leapflow/llm/message_builder.py | 1 + src/leapflow/llm/model_capabilities.py | 1 + src/leapflow/llm/openai_provider.py | 1 + src/leapflow/llm/provider_chain.py | 1 + src/leapflow/llm/provider_registry.py | 1 + src/leapflow/llm/scoped_provider_registry.py | 1 + src/leapflow/logging_setup.py | 1 + src/leapflow/memory/__init__.py | 1 + src/leapflow/memory/manager.py | 1 + src/leapflow/memory/protocol.py | 1 + src/leapflow/memory/providers/__init__.py | 1 + src/leapflow/memory/providers/episodic.py | 1 + src/leapflow/memory/providers/evolution.py | 1 + src/leapflow/memory/providers/narrative.py | 1 + src/leapflow/memory/providers/semantic.py | 1 + src/leapflow/memory/providers/working.py | 1 + src/leapflow/monitor/__init__.py | 1 + .../monitor/capability_adaptation_producer.py | 1 + src/leapflow/monitor/event_bridge.py | 1 + src/leapflow/monitor/evolution_producer.py | 162 +++- src/leapflow/monitor/finding_store.py | 1 + src/leapflow/monitor/manager.py | 1 + .../monitor/plugin_health_producer.py | 1 + src/leapflow/monitor/producers.py | 1 + src/leapflow/monitor/series_extractor.py | 1 + src/leapflow/monitor/session_producer.py | 1 + src/leapflow/monitor/signal_metrics.py | 1 + src/leapflow/monitor/signal_noise.py | 1 + src/leapflow/monitor/signal_producer.py | 1 + src/leapflow/monitor/types.py | 1 + src/leapflow/perception/__init__.py | 1 + .../perception/active_signal_source.py | 1 + .../perception/active_sources/__init__.py | 1 + .../perception/active_sources/discord_bot.py | 1 + .../perception/active_sources/feishu_im.py | 1 + .../perception/active_sources/slack_bot.py | 1 + .../perception/active_sources/telegram_bot.py | 1 + .../perception/active_sources_builtin.py | 1 + src/leapflow/perception/config.py | 1 + src/leapflow/perception/cv/__init__.py | 1 + src/leapflow/perception/cv/optical_flow.py | 1 + src/leapflow/perception/cv/phash.py | 1 + src/leapflow/perception/cv/scene_cut.py | 1 + src/leapflow/perception/cv/text_diff.py | 1 + src/leapflow/perception/cv/ui_detect.py | 1 + src/leapflow/perception/cv_plugins.py | 1 + src/leapflow/perception/cv_processor.py | 1 + src/leapflow/perception/encoding/__init__.py | 1 + src/leapflow/perception/encoding/delta.py | 1 + src/leapflow/perception/encoding/encoder.py | 1 + src/leapflow/perception/encoding/tiler.py | 1 + .../perception/extraction/__init__.py | 1 + .../perception/extraction/extractor.py | 1 + .../extraction/feature_extractor.py | 1 + .../perception/extraction/pipeline.py | 1 + .../perception/extraction/preprocessor.py | 1 + src/leapflow/perception/extraction/refiner.py | 1 + src/leapflow/perception/extraction/router.py | 1 + src/leapflow/perception/implicit_feedback.py | 1 + src/leapflow/perception/sampling/__init__.py | 1 + src/leapflow/perception/session.py | 1 + src/leapflow/perception/signal_source.py | 1 + .../perception/signal_sources_builtin.py | 1 + src/leapflow/perception/signals.py | 1 + src/leapflow/perception/state_snapshot.py | 1 + src/leapflow/perception/storage/__init__.py | 1 + .../perception/storage/deduplicator.py | 1 + .../perception/storage/frame_store.py | 1 + .../perception/storage/semantic_cache.py | 1 + src/leapflow/perception/types.py | 1 + src/leapflow/perception/video/__init__.py | 1 + src/leapflow/perception/video/analyzer.py | 1 + .../perception/video/cache_manager.py | 1 + src/leapflow/perception/video/prompts.py | 1 + src/leapflow/perception/video/recorder.py | 1 + src/leapflow/perception/video/segmenter.py | 1 + src/leapflow/perception/video/timeline.py | 1 + src/leapflow/platform/__init__.py | 1 + src/leapflow/platform/adapters/__init__.py | 1 + src/leapflow/platform/adapters/darwin.py | 1 + src/leapflow/platform/adapters/mock.py | 1 + src/leapflow/platform/capabilities.py | 1 + src/leapflow/platform/client.py | 1 + src/leapflow/platform/cua_client.py | 1 + src/leapflow/platform/event_bus.py | 1 + src/leapflow/platform/facade.py | 1 + src/leapflow/platform/mcp_manager.py | 1 + src/leapflow/platform/mock.py | 1 + src/leapflow/platform/normalizer.py | 1 + src/leapflow/platform/observers/__init__.py | 1 + src/leapflow/platform/observers/app_focus.py | 1 + src/leapflow/platform/observers/clipboard.py | 1 + src/leapflow/platform/observers/daemon.py | 1 + src/leapflow/platform/observers/fs_watcher.py | 1 + src/leapflow/platform/observers/input_tap.py | 1 + src/leapflow/platform/protocol.py | 1 + src/leapflow/platform/relevance.py | 1 + src/leapflow/platform/reorder_buffer.py | 1 + src/leapflow/plugins/__init__.py | 1 + src/leapflow/plugins/_builtin_policies.py | 133 +++ src/leapflow/plugins/adaptive_loop.py | 91 +- src/leapflow/plugins/adaptive_policy.py | 1 + src/leapflow/plugins/capability_plan.py | 1 + src/leapflow/plugins/capability_resolver.py | 103 ++- src/leapflow/plugins/dsh/__init__.py | 1 + src/leapflow/plugins/dsh/bundle.py | 1 + src/leapflow/plugins/dsh/capabilities.py | 1 + src/leapflow/plugins/dsh/descriptor.py | 1 + src/leapflow/plugins/dsh/installer.py | 1 + src/leapflow/plugins/dsh/node_host.py | 1 + src/leapflow/plugins/dsh/plugin.py | 1 + src/leapflow/plugins/dsh/protocol.py | 1 + src/leapflow/plugins/evolution_contracts.py | 1 + src/leapflow/plugins/handler_invocation.py | 1 + src/leapflow/plugins/lifecycle_governor.py | 65 ++ src/leapflow/plugins/marketplace/__init__.py | 1 + src/leapflow/plugins/marketplace/client.py | 1 + .../plugins/marketplace/http_source.py | 1 + src/leapflow/plugins/marketplace/manifest.py | 1 + src/leapflow/plugins/marketplace/server.py | 1 + src/leapflow/plugins/protocol.py | 1 + src/leapflow/plugins/registry.py | 1 + src/leapflow/plugins/sandbox/__init__.py | 1 + src/leapflow/plugins/sandbox/protocol.py | 1 + src/leapflow/plugins/sandbox/sandbox_host.py | 1 + src/leapflow/plugins/sandbox/worker.py | 1 + src/leapflow/plugins/scoped_registry.py | 1 + src/leapflow/plugins/selection_policy.py | 174 ++++ .../plugins/selection_policy_registry.py | 316 +++++++ src/leapflow/plugins/tool_plugins/__init__.py | 1 + .../plugins/tool_plugins/code_intel.py | 1 + .../plugins/tool_plugins/config_tools.py | 1 + .../plugins/tool_plugins/desktop_semantic.py | 1 + .../plugins/tool_plugins/dev_tools.py | 1 + src/leapflow/plugins/tool_plugins/file_ops.py | 1 + src/leapflow/plugins/tool_plugins/gateway.py | 1 + src/leapflow/plugins/tool_plugins/hub.py | 1 + .../plugins/tool_plugins/memory_research.py | 1 + .../plugins/tool_plugins/orchestration.py | 1 + src/leapflow/plugins/tool_plugins/scm_git.py | 1 + .../plugins/tool_plugins/self_management.py | 32 +- .../plugins/tool_plugins/shell_terminal.py | 1 + .../plugins/tool_plugins/skill_discovery.py | 1 + .../plugins/tool_plugins/system_info.py | 1 + .../plugins/tool_plugins/text_utils.py | 1 + .../plugins/tool_plugins/web_access.py | 1 + src/leapflow/privacy/__init__.py | 1 + src/leapflow/privacy/policy.py | 1 + src/leapflow/prompts/__init__.py | 1 + src/leapflow/prompts/templates.py | 1 + src/leapflow/recording/__init__.py | 1 + src/leapflow/recording/attention.py | 1 + src/leapflow/recording/attention_tuner.py | 1 + src/leapflow/recording/field_policy_loader.py | 1 + src/leapflow/recording/health.py | 1 + src/leapflow/recording/perceptual_field.py | 1 + src/leapflow/recording/recorder.py | 1 + src/leapflow/scheduler/__init__.py | 1 + src/leapflow/scheduler/cloud_dispatcher.py | 1 + src/leapflow/scheduler/compute/__init__.py | 1 + .../scheduler/compute/modelscope_studio.py | 1 + src/leapflow/scheduler/compute/protocol.py | 1 + src/leapflow/scheduler/coordinator.py | 1 + src/leapflow/scheduler/local_scheduler.py | 1 + src/leapflow/scheduler/reentry_driver.py | 1 + src/leapflow/scheduler/reentry_send.py | 1 + src/leapflow/scheduler/reentry_service.py | 1 + src/leapflow/scheduler/store.py | 1 + src/leapflow/scheduler/triggers/__init__.py | 1 + src/leapflow/scheduler/triggers/condition.py | 1 + src/leapflow/scheduler/triggers/cron.py | 1 + src/leapflow/scheduler/triggers/event.py | 1 + src/leapflow/scheduler/triggers/interval.py | 1 + src/leapflow/scheduler/types.py | 1 + src/leapflow/scheduler/worker_packager.py | 1 + src/leapflow/security/__init__.py | 1 + src/leapflow/security/actions.py | 1 + src/leapflow/security/approval.py | 1 + src/leapflow/security/grants.py | 1 + src/leapflow/security/network.py | 1 + src/leapflow/security/orchestrator.py | 1 + src/leapflow/security/path_sensitivity.py | 1 + src/leapflow/security/permission_failures.py | 1 + src/leapflow/security/policy.py | 1 + src/leapflow/security/redact.py | 1 + src/leapflow/security/risk.py | 1 + src/leapflow/security/secrets.py | 1 + src/leapflow/security/send_trust.py | 1 + src/leapflow/security/threat_patterns.py | 1 + src/leapflow/signal_fusion/__init__.py | 1 + src/leapflow/signal_fusion/action_agent.py | 1 + src/leapflow/signal_fusion/cross_app.py | 1 + src/leapflow/signal_fusion/episode_agent.py | 1 + src/leapflow/signal_fusion/integrator.py | 1 + src/leapflow/signal_fusion/pipeline.py | 1 + src/leapflow/signal_fusion/protocol.py | 1 + src/leapflow/signal_fusion/quality.py | 1 + src/leapflow/signal_fusion/segment_agent.py | 1 + src/leapflow/signal_fusion/types.py | 1 + src/leapflow/signal_fusion/wait_classifier.py | 1 + src/leapflow/skills/__init__.py | 1 + src/leapflow/skills/action_policy.py | 1 + src/leapflow/skills/activator.py | 1 + src/leapflow/skills/builtin/__init__.py | 1 + src/leapflow/skills/builtin/app_launcher.py | 1 + .../skills/builtin/clipboard_manager.py | 1 + src/leapflow/skills/builtin/file_organizer.py | 1 + src/leapflow/skills/conditions.py | 1 + src/leapflow/skills/discovery.py | 1 + src/leapflow/skills/evolution.py | 1 + src/leapflow/skills/index.py | 1 + src/leapflow/skills/injector.py | 1 + src/leapflow/skills/registry.py | 1 + src/leapflow/skills/sandbox.py | 1 + src/leapflow/skills/semantic_adapter.py | 1 + src/leapflow/skills/semantic_schema.py | 1 + src/leapflow/skills/tool_executor.py | 1 + src/leapflow/storage/__init__.py | 1 + src/leapflow/storage/bundle_writer.py | 1 + .../storage/capability_observation_store.py | 15 + src/leapflow/storage/capability_plan_store.py | 1 + .../storage/capability_proposal_queue.py | 1 + src/leapflow/storage/connection.py | 1 + src/leapflow/storage/conversation_store.py | 1 + src/leapflow/storage/db_repair.py | 1 + .../storage/distilled_knowledge_store.py | 317 +++++++ src/leapflow/storage/duckdb_connect.py | 1 + src/leapflow/storage/evolution_store.py | 1 + src/leapflow/storage/evolution_trace_store.py | 1 + src/leapflow/storage/plugin_outcome_store.py | 1 + src/leapflow/storage/plugin_proposal_store.py | 1 + src/leapflow/storage/plugin_version_store.py | 1 + src/leapflow/storage/reentry_store.py | 1 + src/leapflow/storage/research_ledger_store.py | 1 + src/leapflow/storage/schema.py | 1 + src/leapflow/storage/session_store.py | 1 + src/leapflow/storage/skill_docs.py | 1 + src/leapflow/storage/skill_library.py | 1 + src/leapflow/storage/trajectory_store.py | 1 + src/leapflow/storage/write_buffer.py | 1 + src/leapflow/telemetry/__init__.py | 1 + src/leapflow/telemetry/evolution_tap.py | 1 + src/leapflow/tools/__init__.py | 1 + src/leapflow/tools/code_intel.py | 1 + src/leapflow/tools/config_tools.py | 15 + src/leapflow/tools/dev_tools.py | 1 + src/leapflow/tools/execution_context.py | 1 + src/leapflow/tools/file_operations.py | 16 +- src/leapflow/tools/gateway_tool.py | 1 + src/leapflow/tools/hub_tool.py | 1 + src/leapflow/tools/name_resolver.py | 1 + src/leapflow/tools/repo_map.py | 1 + src/leapflow/tools/scm_tools.py | 1 + src/leapflow/tools/shell_tools.py | 1 + src/leapflow/tools/system_tools.py | 1 + src/leapflow/tools/terminal_session.py | 1 + src/leapflow/tools/text_tools.py | 1 + src/leapflow/tools/web_cache.py | 1 + src/leapflow/tools/web_extract.py | 1 + src/leapflow/tools/web_fetch.py | 1 + src/leapflow/utils/__init__.py | 1 + src/leapflow/utils/build_info.py | 1 + src/leapflow/utils/diagnostics.py | 1 + src/leapflow/utils/file_lock.py | 1 + src/leapflow/utils/process_group.py | 1 + src/leapflow/utils/progress.py | 1 + src/leapflow/utils/resilience.py | 1 + src/leapflow/utils/shell_lex.py | 1 + src/leapflow/utils/stream_progress.py | 1 + src/leapflow/utils/terminal_io.py | 1 + src/leapflow/version.py | 1 + src/leapflow/world_model/__init__.py | 1 + src/leapflow/world_model/_json_utils.py | 1 + src/leapflow/world_model/budget.py | 1 + src/leapflow/world_model/curiosity.py | 1 + src/leapflow/world_model/embedding.py | 1 + src/leapflow/world_model/experience_store.py | 1 + src/leapflow/world_model/orientation.py | 1 + src/leapflow/world_model/prediction.py | 1 + src/leapflow/world_model/replay.py | 1 + src/leapflow/world_model/trajectory_grader.py | 412 +++++++-- src/leapspace/__init__.py | 1 + src/leapspace/app_space/__init__.py | 1 + src/leapspace/app_space/action_lint.py | 1 + src/leapspace/app_space/action_utils.py | 1 + src/leapspace/app_space/actor.py | 1 + src/leapspace/app_space/apps/__init__.py | 1 + src/leapspace/app_space/apps/_base.py | 1 + src/leapspace/app_space/apps/chat.py | 1 + src/leapspace/app_space/config.py | 1 + src/leapspace/app_space/e2e.py | 1 + src/leapspace/app_space/event_view.py | 1 + src/leapspace/app_space/harness.py | 1 + src/leapspace/app_space/signal.py | 1 + .../app_space/tasks/task-001/action.py | 1 + src/leapspace/app_space/utils.py | 1 + .../plugin_exp/scripts/adaptive_plugin_exp.py | 1 + .../scripts/native_dsh_plugin_exp.py | 1 + tests/__init__.py | 1 + tests/_harness/__init__.py | 1 + tests/_harness/cassette.py | 1 + tests/_harness/cassette_proxy.py | 1 + tests/_harness/journey.py | 1 + tests/_harness/leapd.py | 1 + tests/conftest.py | 1 + tests/journeys/__init__.py | 1 + tests/journeys/conftest.py | 1 + tests/journeys/test_r1_conversation.py | 1 + tests/journeys/test_r2_isolation.py | 1 + tests/journeys/test_r3_control_plane.py | 1 + tests/journeys/test_r4_recovery.py | 1 + tests/journeys/test_r5_learning.py | 1 + tests/journeys/test_r6_lifecycle.py | 1 + .../journeys/test_r7_adaptive_plugin_loop.py | 1 + tests/journeys/test_r8_hardware.py | 1 + tests/leapspace/test_action_lint.py | 1 + tests/leapspace/test_action_utils.py | 1 + tests/leapspace/test_actor.py | 1 + tests/leapspace/test_base.py | 1 + tests/leapspace/test_config.py | 1 + tests/leapspace/test_event_view.py | 1 + tests/leapspace/test_harness.py | 1 + tests/leapspace/test_signal.py | 1 + tests/leapspace/test_utils.py | 1 + tests/mock_signals/__init__.py | 1 + tests/mock_signals/__main__.py | 1 + tests/mock_signals/generators.py | 1 + tests/mock_signals/profiles.py | 1 + tests/mock_signals/runner.py | 1 + tests/regression/__init__.py | 1 + tests/regression/test_impact_selection.py | 1 + tests/regression/test_incident_ledger.py | 1 + tests/regression/test_provider_shape_drift.py | 1 + tests/regression/test_suite_budget.py | 1 + tests/regression/test_test_layer_contracts.py | 1 + tests/test_active_signal_source.py | 1 + tests/test_adaptation_verdict.py | 466 ++++++++++ tests/test_adaptive_depth.py | 1 + tests/test_adaptive_plugin_loop.py | 1 + tests/test_agent_execution.py | 1 + tests/test_app_connector.py | 1 + tests/test_approval_layer.py | 1 + tests/test_architecture_contracts.py | 62 +- tests/test_board_session_binding.py | 1 + tests/test_budget_calibration.py | 1 + tests/test_build_info.py | 1 + tests/test_cache_manager.py | 1 + tests/test_capability_adaptation_producer.py | 1 + tests/test_capability_gap_detector.py | 1 + tests/test_capability_observation.py | 1 + tests/test_capability_observation_store.py | 1 + tests/test_capability_plan.py | 1 + tests/test_capability_plan_store.py | 1 + tests/test_capability_proposal_policy.py | 1 + tests/test_capability_replacement_trigger.py | 835 ++++++++++++++++++ ..._capability_requirement_and_environment.py | 1 + tests/test_capability_resolver.py | 16 +- tests/test_cli_discovery.py | 1 + tests/test_cli_entrypoint.py | 1 + tests/test_cli_hardware.py | 1 + tests/test_cli_ndjson_event_source.py | 1 + tests/test_code_tools.py | 1 + tests/test_coevolution_observations.py | 1 + tests/test_coevolution_sweep_wiring.py | 14 + tests/test_compatibility_assessment.py | 1 + tests/test_concurrent_workspace_governance.py | 1 + tests/test_config_and_path_contracts.py | 1 + tests/test_config_capability_tools.py | 35 + tests/test_config_loader.py | 1 + tests/test_context_budget_scaling.py | 1 + tests/test_context_disclosure.py | 1 + tests/test_context_focus.py | 1 + tests/test_context_governance.py | 1 + tests/test_context_misbinding_regression.py | 1 + tests/test_cua_client_mapping.py | 1 + tests/test_cv_plugins.py | 1 + tests/test_daemon_event_loop_blocking.py | 1 + tests/test_daemon_isolation.py | 1 + tests/test_daemon_rpc.py | 1 + tests/test_daemon_transport.py | 1 + tests/test_darwin_adapter.py | 1 + tests/test_dashboard_domains.py | 1 + tests/test_dashboard_i18n_static.py | 1 + tests/test_dashboard_launcher.py | 1 + tests/test_dashboard_sdui.py | 1 + tests/test_dashboard_view.py | 1 + tests/test_dashboard_watch_rpc.py | 1 + tests/test_deferred_init_responsiveness.py | 1 + tests/test_degradation_feedback_loop.py | 488 ++++++++++ tests/test_dependency_activation.py | 1 + tests/test_dev_terminal_tools.py | 1 + tests/test_distilled_knowledge.py | 417 +++++++++ tests/test_distilled_preference.py | 226 +++++ tests/test_dsh_compatibility.py | 1 + tests/test_effect_declaration.py | 152 +++- tests/test_effect_scope.py | 1 + tests/test_empty_response_hardening.py | 1 + tests/test_environment_catalog.py | 1 + tests/test_event_bridge.py | 1 + tests/test_event_driven_watch.py | 1 + tests/test_evolution_governance_reachable.py | 1 + tests/test_evolution_ledger.py | 1 + tests/test_evolution_producer.py | 196 +++- tests/test_evolution_tap.py | 1 + tests/test_evolution_verify_and_govern.py | 1 + tests/test_execution_backends.py | 1 + tests/test_facade_capability_mapping.py | 1 + tests/test_feishu_event_normalizer.py | 1 + tests/test_file_lock.py | 1 + tests/test_frame_store_protocol.py | 1 + tests/test_full_fiberization.py | 1 + tests/test_gateway_adapter_registry.py | 1 + tests/test_gateway_adapters.py | 1 + tests/test_gateway_consumer_loop.py | 1 + tests/test_gateway_tool_e2e.py | 1 + .../test_hardware_alert_and_observability.py | 1 + tests/test_hardware_context.py | 1 + tests/test_hardware_governance.py | 1 + tests/test_hardware_host_discovery.py | 1 + tests/test_hardware_integration.py | 1 + tests/test_hardware_longevity.py | 1 + tests/test_hardware_media.py | 1 + tests/test_hardware_observability.py | 1 + tests/test_hardware_outcome.py | 1 + tests/test_hardware_reading_store.py | 1 + tests/test_hardware_replay_audit.py | 1 + tests/test_hardware_signal_path.py | 1 + tests/test_hardware_stream.py | 1 + tests/test_hardware_transport_contract.py | 1 + tests/test_hardware_write_preview.py | 1 + tests/test_im_signal_sources.py | 1 + tests/test_inert_wiring_audit.py | 212 +++++ tests/test_internal_defect_reporting.py | 1 + tests/test_journey_harness.py | 1 + tests/test_layout.py | 1 + tests/test_lifecycle_governor.py | 1 + tests/test_llm_coevolution_e2e.py | 1 + tests/test_llm_provider_registry.py | 1 + tests/test_marketplace_server.py | 1 + tests/test_marketplace_signing.py | 1 + tests/test_mcp_governance.py | 1 + tests/test_memory_and_storage.py | 1 + tests/test_mock_hardware_signals.py | 1 + tests/test_monitor_signal_noise.py | 1 + tests/test_monitor_subsystem.py | 1 + tests/test_multi_client_session_isolation.py | 1 + tests/test_observation_lifecycle.py | 1 + tests/test_orientation.py | 1 + tests/test_path_sensitivity.py | 1 + tests/test_perception_pipeline.py | 1 + tests/test_phase3_learning_autonomy.py | 1 + tests/test_platform_adapters.py | 1 + tests/test_platform_synthesis.py | 1 + tests/test_plugin_behavior_tests.py | 1 + tests/test_plugin_generator.py | 1 + tests/test_plugin_learning.py | 1 + tests/test_plugin_marketplace.py | 1 + tests/test_plugin_plan_introspection.py | 1 + tests/test_plugin_proposal_store.py | 1 + tests/test_plugin_reload.py | 1 + tests/test_plugin_sandbox.py | 1 + tests/test_plugin_stats_persistence.py | 1 + tests/test_plugin_version_store.py | 1 + tests/test_process_group.py | 1 + tests/test_pure_algorithms.py | 1 + tests/test_recovery_audit.py | 1 + tests/test_recovery_checkpoint.py | 1 + tests/test_recovery_contract_e2e.py | 1 + tests/test_recovery_coordinator.py | 1 + tests/test_recovery_strategies.py | 1 + tests/test_reentry_driver.py | 1 + tests/test_reentry_send.py | 1 + tests/test_reentry_service.py | 1 + tests/test_reentry_store.py | 1 + tests/test_reorder_buffer_capacity.py | 1 + tests/test_repo_map.py | 1 + tests/test_runtime_metadata_and_wrapping.py | 1 + tests/test_safety_and_policy.py | 1 + tests/test_scm_tools.py | 1 + tests/test_scoped_registry.py | 1 + tests/test_selection_policy.py | 607 +++++++++++++ tests/test_self_evolution_switch.py | 174 ++++ tests/test_self_management.py | 1 + tests/test_semantic_adapter.py | 1 + tests/test_semantic_schema.py | 1 + tests/test_series_extractor.py | 1 + tests/test_session_analysis.py | 1 + tests/test_session_factory.py | 1 + tests/test_session_registry.py | 1 + tests/test_signal_buffer_overflow.py | 1 + tests/test_signal_noise.py | 1 + tests/test_signal_source.py | 1 + tests/test_skill_lifecycle.py | 1 + tests/test_slash_command_router.py | 1 + tests/test_teach_learn_lifecycle.py | 1 + tests/test_teacher_capability_validation.py | 60 +- tests/test_telegram_signal_source.py | 1 + tests/test_tool_call_hardening.py | 1 + tests/test_tool_capability_declaration.py | 1 + tests/test_tool_concurrency.py | 1 + tests/test_tool_handler_invocation.py | 1 + tests/test_tool_pipeline.py | 1 + tests/test_tool_registry_conflict.py | 1 + tests/test_transport_discovery.py | 1 + tests/test_trigger_policy.py | 1 + tests/test_tui_command_queue.py | 1 + tests/test_tui_session_summary.py | 1 + tests/test_tui_theme.py | 1 + tests/test_tui_tool_audit.py | 1 + tests/test_turn_admission.py | 1 + tests/test_turn_admission_parking.py | 1 + .../test_uncertain_effect_and_interaction.py | 1 + tests/test_unified_classifier.py | 1 + tests/test_visual_pipeline.py | 1 + tests/test_web_fetch.py | 1 + tests/test_workspace_escape_approval.py | 1 + tests/test_world_model.py | 1 + tests/test_world_model_driven_evolution_p1.py | 18 +- tests/test_world_model_driver.py | 33 +- tests/test_world_model_evolution_p0.py | 1 + tools/audit_inert_wiring.py | 183 ++++ tools/impact.py | 1 + tools/sync_fixtures.py | 1 + 849 files changed, 8330 insertions(+), 224 deletions(-) create mode 100644 src/leapflow/domain/adaptation_verdict.py create mode 100644 src/leapflow/learning/degradation_sink.py create mode 100644 src/leapflow/plugins/_builtin_policies.py create mode 100644 src/leapflow/plugins/selection_policy.py create mode 100644 src/leapflow/plugins/selection_policy_registry.py create mode 100644 src/leapflow/storage/distilled_knowledge_store.py create mode 100644 tests/test_adaptation_verdict.py create mode 100644 tests/test_capability_replacement_trigger.py create mode 100644 tests/test_degradation_feedback_loop.py create mode 100644 tests/test_distilled_knowledge.py create mode 100644 tests/test_distilled_preference.py create mode 100644 tests/test_inert_wiring_audit.py create mode 100644 tests/test_selection_policy.py create mode 100644 tests/test_self_evolution_switch.py create mode 100644 tools/audit_inert_wiring.py diff --git a/docs/plugins/third_party_plugin_development.md b/docs/plugins/third_party_plugin_development.md index df3d29cd..d0c870a8 100644 --- a/docs/plugins/third_party_plugin_development.md +++ b/docs/plugins/third_party_plugin_development.md @@ -39,6 +39,7 @@ config, gateway dispatch) plus the Tool Capability Contract in | `ToolPlugin` | `plugins/protocol.py` | Register callable tools exposed to the LLM agent | | `GatewayAdapterPlugin` | `gateway/adapter_registry.py` | Factory for IM/platform adapters (Feishu, Telegram, etc.) | | `LLMProviderPlugin` | `llm/provider_registry.py` | Register alternative LLM backends | +| `SelectionPolicyPlugin` | `plugins/selection_policy.py` | Decide which admissible tool candidate to use, and learn from the result | | `SignalSource` | `perception/signal_source.py` | Stateless event → signal transform | | `ActiveSignalSource` | `perception/active_signal_source.py` | Long-running signal emitter (webhook listener, polling bot) | | `CVProcessor` | `perception/cv_processor.py` | Frame-pair visual diff processing | @@ -53,6 +54,10 @@ Additionally, `FrameStore` (`perception/storage/frame_store.py`) is a `@runtime_ - **ToolPlugin** — You want the LLM agent to invoke your functionality as a tool call (most common). - **GatewayAdapterPlugin** — You are integrating a new IM/collaboration platform. - **LLMProviderPlugin** — You are adding a new LLM API backend (e.g., a private deployment). +- **SelectionPolicyPlugin** — You are implementing a strategy for choosing between tools that + provide the same capability (greedy, Thompson sampling, UCB). Your policy sees only + candidates that already passed every hard constraint, and must be able to explain each + choice. - **SignalSource** — You need to normalize external events into LeapFlow's signal pipeline (stateless, transform-only). - **ActiveSignalSource** — You need a long-running listener that emits signals (websocket, polling loop). - **CVProcessor** — You are implementing a visual diff algorithm for the perception subsystem. @@ -195,9 +200,80 @@ class LLMProviderPlugin(Protocol): def create_provider(self, config: Dict[str, Any]) -> LLMProvider: ... ``` -LLM provider plugins support **entry_point discovery** via setuptools group `"leapflow.llm_providers"`. This is the only Protocol that supports entry_point-based discovery. +LLM provider plugins support **entry_point discovery** via setuptools group `"leapflow.llm_providers"`. -### 2.5 SignalSource Protocol +### 2.5 SelectionPolicyPlugin Protocol + +When several tools provide the same capability, a selection policy decides which one runs. +It is a *core extension point*, not a tool plugin: it is invoked by the framework inside a +turn, reads host-side services, and therefore is **not** sandboxed, **not** approval-gated, +and earns **no** Progressive Trust — trust is earned by executing tools, and a policy +executes none. + +```python +@runtime_checkable +class SelectionPolicyPlugin(Protocol): + @property + def policy_id(self) -> str: ... # config value, e.g. "greedy" + + @property + def display_name(self) -> str: ... + + def create(self, params: Mapping[str, Any], deps: PolicyDeps) -> SelectionPolicy: ... + + +@runtime_checkable +class SelectionPolicy(Protocol): + policy_id: str + + def select(self, requirement, eligible, context) -> SelectionOutcome: ... + def observe(self, requirement, chosen_tool: str, reward: RewardSignal) -> None: ... +``` + +Discovery: setuptools group `"leapflow.selection_policies"`. Activation: +`selection.policy` via `leap config set selection.policy `. + +Built in: `greedy` (default, highest score), `thompson` (Beta posterior sampling), +`ucb1` (deterministic upper confidence bound), `bucketed` (routes capabilities between +several policies for an A/B experiment; arms come from `selection.buckets`). The +learners share `selection.prior_strength`; `ucb1` also reads `selection.exploration`. + +> **Competition is required for a learning policy to do anything.** With built-in +> tools alone, every capability name is provided by exactly one tool (55 tools, 55 +> names), so there is one arm per capability and nothing to learn. Multiple candidates +> appear when self-evolution generates a plugin providing a capability an existing +> tool already provides. Until then `thompson` and `ucb1` behave as greedy-with-prior. + +Three rules the runtime enforces, each of which will otherwise bite you: + +- **`eligible` is pre-filtered and never empty.** Any candidate a configured scorer marked + `excluded` is removed *before* `select` is called, and your policy cannot reach it. With + the default scorer set that covers: a candidate not declaring the capability, a missing + host platform capability, and a risk level above the requirement's ceiling. Two further + exclusions — app-level affordance and frozen-trust — come from scorers that are opt-in + (`CapabilityResolver(scorers=...)`), so they apply only where a caller injects them. + Exploration is never permitted to trade off safety, but do not read this list as the + complete set of guards in every configuration. +- **`SelectionOutcome.reason` is rendered to operators**, and `explored=True` is how a + deliberate departure from the highest score is distinguished from a scoring bug. "Chose + at random" is not an explanation; a posterior and a bonus term are. +- **`RewardSignal.value is None` means *no information*, not failure.** A successful call + whose handler declared no observable effect abstains. Treating that as a zero would drive + every arm's posterior down and, through trust, quarantine healthy plugins for a reporting + omission. Only `observed_effect` in a tool result carries a confirmable effect. + +Registration is **first-wins**: an id already taken is refused and the collision recorded, +rather than silently replacing how the framework chooses its own tools. Instance lifetime is +owned by the registry (`activate()` builds once and caches, `current()` never creates), so a +stateful policy accumulates across turns instead of being rebuilt per call. + +A policy that carries a posterior must persist it. `PolicyDeps.stats_store` provides a +durable per-`(policy, capability, arm)` counter store; keeping the posterior in memory +instead means restarting cold on every daemon restart and never converging. Reads are +answered from memory because `select()` is on the turn path; writes happen in `observe()`, +which runs on the cold-path sweep. + +### 2.6 SignalSource Protocol ```python @runtime_checkable @@ -217,7 +293,7 @@ class SignalSource(Protocol): Stateless; not fiber-managed. Registered with `SignalSourceRegistry`. -### 2.6 ActiveSignalSource Protocol +### 2.7 ActiveSignalSource Protocol ```python EmitCallback = Callable[[InteractionSignal], None] @@ -236,7 +312,7 @@ class ActiveSignalSource(Protocol): Managed by `ActiveSourceManager` (bounded asyncio queue, per-source task, thread-safe emit callback). **Note:** ActiveSignalSource is not yet integrated with PluginFiber lifecycle; lifecycle is owned by `PerceptionSession` directly. -### 2.7 CVProcessor Protocol +### 2.8 CVProcessor Protocol ```python @runtime_checkable diff --git a/src/leapflow/__init__.py b/src/leapflow/__init__.py index 57ade116..c550647a 100644 --- a/src/leapflow/__init__.py +++ b/src/leapflow/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapFlow — Learning and Evolving from Actual Practice.""" from leapflow.config import load_config diff --git a/src/leapflow/__main__.py b/src/leapflow/__main__.py index 8ea47f51..e5ca69dc 100644 --- a/src/leapflow/__main__.py +++ b/src/leapflow/__main__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """CLI entrypoint for LeapFlow. Subcommands: diff --git a/src/leapflow/analysis/__init__.py b/src/leapflow/analysis/__init__.py index 47e3f717..a4841e46 100644 --- a/src/leapflow/analysis/__init__.py +++ b/src/leapflow/analysis/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Offline analysis layer — synthesis, abstraction, segmentation, and distillation pipeline.""" from leapflow.analysis.abstractor import ActionAbstractor diff --git a/src/leapflow/analysis/abstractor.py b/src/leapflow/analysis/abstractor.py index 24ae11e7..32720d0d 100644 --- a/src/leapflow/analysis/abstractor.py +++ b/src/leapflow/analysis/abstractor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Multi-level action abstraction for trajectory analysis. Abstraction levels: diff --git a/src/leapflow/analysis/causal.py b/src/leapflow/analysis/causal.py index e46858f5..2c782a1f 100644 --- a/src/leapflow/analysis/causal.py +++ b/src/leapflow/analysis/causal.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Causal chain extraction from semantic action sequences. Identifies only the actions that contribute to the final observable state, diff --git a/src/leapflow/analysis/consensus.py b/src/leapflow/analysis/consensus.py index a482847a..1f275045 100644 --- a/src/leapflow/analysis/consensus.py +++ b/src/leapflow/analysis/consensus.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cross-trajectory consensus distillation. When a user naturally performs the same task multiple times, each recording diff --git a/src/leapflow/analysis/denoise.py b/src/leapflow/analysis/denoise.py index 7382b87e..63a3a67c 100644 --- a/src/leapflow/analysis/denoise.py +++ b/src/leapflow/analysis/denoise.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Noise-robust preprocessing for demonstration trajectories. Implements DenoisePass as a composable AbstractionPass that runs before diff --git a/src/leapflow/analysis/environment_catalog.py b/src/leapflow/analysis/environment_catalog.py index d2aa26ae..c82a4415 100644 --- a/src/leapflow/analysis/environment_catalog.py +++ b/src/leapflow/analysis/environment_catalog.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Declarative environment marker catalog for adaptive capability selection.""" from __future__ import annotations diff --git a/src/leapflow/analysis/environment_probe.py b/src/leapflow/analysis/environment_probe.py index 59dfb627..dd4442f1 100644 --- a/src/leapflow/analysis/environment_probe.py +++ b/src/leapflow/analysis/environment_probe.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Structured environment probing for adaptive capability selection. The probe only observes explicit structural facts supplied by its caller: diff --git a/src/leapflow/analysis/episode_dedup.py b/src/leapflow/analysis/episode_dedup.py index e95d5f28..b215f6a1 100644 --- a/src/leapflow/analysis/episode_dedup.py +++ b/src/leapflow/analysis/episode_dedup.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Pre-distillation episode deduplication via structural fingerprinting. Prevents redundant LLM calls by grouping structurally identical episodes diff --git a/src/leapflow/analysis/fs_pattern_pass.py b/src/leapflow/analysis/fs_pattern_pass.py index 223918ff..18d99efa 100644 --- a/src/leapflow/analysis/fs_pattern_pass.py +++ b/src/leapflow/analysis/fs_pattern_pass.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """File system event pattern recognition pass. Identifies high-level file operation patterns from low-level FS events, diff --git a/src/leapflow/analysis/intent_inferrer.py b/src/leapflow/analysis/intent_inferrer.py index 96989a5c..b4162a49 100644 --- a/src/leapflow/analysis/intent_inferrer.py +++ b/src/leapflow/analysis/intent_inferrer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Episode intent inference using LLM or rule-based fallback. Responsible for determining the user's high-level goal from diff --git a/src/leapflow/analysis/patterns.py b/src/leapflow/analysis/patterns.py index c8cf16aa..fc0a1367 100644 --- a/src/leapflow/analysis/patterns.py +++ b/src/leapflow/analysis/patterns.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Extensible action pattern library for trajectory abstraction. Supports YAML-driven pattern definitions with wildcard matching, diff --git a/src/leapflow/analysis/pipeline.py b/src/leapflow/analysis/pipeline.py index c54a062f..97c093a9 100644 --- a/src/leapflow/analysis/pipeline.py +++ b/src/leapflow/analysis/pipeline.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Imitation learning pipeline — orchestrates the full observe → distill workflow. Coordinates: diff --git a/src/leapflow/analysis/segmenter.py b/src/leapflow/analysis/segmenter.py index 9b5d676b..f032e995 100644 --- a/src/leapflow/analysis/segmenter.py +++ b/src/leapflow/analysis/segmenter.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Episode segmentation — split trajectories into semantically coherent chunks. Uses a chain of heuristic boundary detectors (Strategy pattern) so new diff --git a/src/leapflow/analysis/synthesis.py b/src/leapflow/analysis/synthesis.py index 1cc6e1eb..1a335ce4 100644 --- a/src/leapflow/analysis/synthesis.py +++ b/src/leapflow/analysis/synthesis.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Platform-aware event synthesis — merges low-level OS events into high-level operations. Sits between DenoisePass and GroupingPass in the abstraction pipeline. diff --git a/src/leapflow/cache/__init__.py b/src/leapflow/cache/__init__.py index 8ddc06df..88d0e767 100644 --- a/src/leapflow/cache/__init__.py +++ b/src/leapflow/cache/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cache lifecycle primitives for LeapFlow.""" from leapflow.cache.manager import CacheEntry, CacheManager, CacheScope diff --git a/src/leapflow/cache/manager.py b/src/leapflow/cache/manager.py index aa65306a..025ab4d3 100644 --- a/src/leapflow/cache/manager.py +++ b/src/leapflow/cache/manager.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DuckDB-indexed cache manager for profile/workspace/session scopes.""" from __future__ import annotations diff --git a/src/leapflow/causal/__init__.py b/src/leapflow/causal/__init__.py index a5773123..f1029521 100644 --- a/src/leapflow/causal/__init__.py +++ b/src/leapflow/causal/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Causal Propagation Chain — unified causal model for LeapFlow. Core data model: CausalEvent → CausalChain → CausalGraph. diff --git a/src/leapflow/causal/adapter.py b/src/leapflow/causal/adapter.py index 403da6e4..360fdca8 100644 --- a/src/leapflow/causal/adapter.py +++ b/src/leapflow/causal/adapter.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Adapters bridging CausalGraph to downstream pipeline interfaces. Conversions: diff --git a/src/leapflow/causal/channel.py b/src/leapflow/causal/channel.py index 3712caa0..df5dad8e 100644 --- a/src/leapflow/causal/channel.py +++ b/src/leapflow/causal/channel.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Channel abstraction: table-driven behavior for all signal channels. ChannelSpec defines a channel's causal role, aggregation policy, and diff --git a/src/leapflow/causal/components.py b/src/leapflow/causal/components.py index 55a39210..d1038ac3 100644 --- a/src/leapflow/causal/components.py +++ b/src/leapflow/causal/components.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Layer 2 front-end components — lightweight processors that transform raw CausalEvents into structured CausalChains. diff --git a/src/leapflow/causal/inference.py b/src/leapflow/causal/inference.py index f6b1ab4f..2602bc30 100644 --- a/src/leapflow/causal/inference.py +++ b/src/leapflow/causal/inference.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """3-tier causal inference engine. Tier 1: Rule-based (deterministic, zero cost, confidence ≥ 0.9) diff --git a/src/leapflow/causal/pipeline.py b/src/leapflow/causal/pipeline.py index 6064d077..7d4c415c 100644 --- a/src/leapflow/causal/pipeline.py +++ b/src/leapflow/causal/pipeline.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Causal Fusion Pipeline — Layer 1 + Layer 2 orchestrator. Receives raw signals/system events, emits a populated CausalGraph with diff --git a/src/leapflow/causal/types.py b/src/leapflow/causal/types.py index 7bb38783..f3591fd5 100644 --- a/src/leapflow/causal/types.py +++ b/src/leapflow/causal/types.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Core data model for the Causal Propagation Chain. Three levels of abstraction: diff --git a/src/leapflow/cli/__init__.py b/src/leapflow/cli/__init__.py index 1eea6edb..e5c438ae 100644 --- a/src/leapflow/cli/__init__.py +++ b/src/leapflow/cli/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """CLI package — entry point and subcommand handlers.""" from leapflow.cli.cli import main diff --git a/src/leapflow/cli/approval_view.py b/src/leapflow/cli/approval_view.py index 21947b1e..c55e8250 100644 --- a/src/leapflow/cli/approval_view.py +++ b/src/leapflow/cli/approval_view.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Terminal approval view helpers for LeapFlow CLI/TUI surfaces.""" from __future__ import annotations diff --git a/src/leapflow/cli/banner.py b/src/leapflow/cli/banner.py index 1edd720a..511bfbdc 100644 --- a/src/leapflow/cli/banner.py +++ b/src/leapflow/cli/banner.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapFlow welcome banner. Two display modes: diff --git a/src/leapflow/cli/cli.py b/src/leapflow/cli/cli.py index 66c5334c..8054ef3e 100644 --- a/src/leapflow/cli/cli.py +++ b/src/leapflow/cli/cli.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """CLI entrypoint for LeapFlow. Usage: diff --git a/src/leapflow/cli/commands/__init__.py b/src/leapflow/cli/commands/__init__.py index 54a49184..503ccf83 100644 --- a/src/leapflow/cli/commands/__init__.py +++ b/src/leapflow/cli/commands/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """CLI subcommand handlers.""" diff --git a/src/leapflow/cli/commands/chat.py b/src/leapflow/cli/commands/chat.py index 9bae402a..e3b9f283 100644 --- a/src/leapflow/cli/commands/chat.py +++ b/src/leapflow/cli/commands/chat.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Chat subcommand — single-turn conversational mode with rich output.""" from __future__ import annotations diff --git a/src/leapflow/cli/commands/config.py b/src/leapflow/cli/commands/config.py index 1f4dda84..0c9efe21 100644 --- a/src/leapflow/cli/commands/config.py +++ b/src/leapflow/cli/commands/config.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unified config CLI for LeapFlow.""" from __future__ import annotations diff --git a/src/leapflow/cli/commands/daemon.py b/src/leapflow/cli/commands/daemon.py index 4b7fd323..7ae6ab4d 100644 --- a/src/leapflow/cli/commands/daemon.py +++ b/src/leapflow/cli/commands/daemon.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """CLI commands for leapd daemon management. ``leap daemon status`` — show whether leapd is running diff --git a/src/leapflow/cli/commands/dashboard.py b/src/leapflow/cli/commands/dashboard.py index 3839704b..8a6f04a4 100644 --- a/src/leapflow/cli/commands/dashboard.py +++ b/src/leapflow/cli/commands/dashboard.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """`leap board` — open or serve the LeapBoard monitoring web dashboard. Two modes: diff --git a/src/leapflow/cli/commands/hardware.py b/src/leapflow/cli/commands/hardware.py index b00ef162..339bd351 100644 --- a/src/leapflow/cli/commands/hardware.py +++ b/src/leapflow/cli/commands/hardware.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """`leap hw` — inspect hardware and intervene in it directly (Phase 1.4). Two planes share one command group: diff --git a/src/leapflow/cli/commands/host.py b/src/leapflow/cli/commands/host.py index 6d3db6ed..8d4747d6 100644 --- a/src/leapflow/cli/commands/host.py +++ b/src/leapflow/cli/commands/host.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Platform driver lifecycle management commands — cua-driver + ObservationDaemon. Manages the cua-driver execution layer and ObservationDaemon background diff --git a/src/leapflow/cli/commands/hub.py b/src/leapflow/cli/commands/hub.py index a953f036..3b4b12aa 100644 --- a/src/leapflow/cli/commands/hub.py +++ b/src/leapflow/cli/commands/hub.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hub CLI commands — push, pull, sync, search, list, login, whoami. Provides the ``leap hub`` subcommand family for cloud skill collaboration diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index a137b975..5830add0 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Interactive subcommand — persistent REPL with hybrid Application TUI. Uses ``LeapApp`` (prompt_toolkit Application + Rich) for a Hermes-style @@ -248,6 +249,32 @@ def _print_host_status(console: Any, host: dict[str, Any]) -> None: console.warning(f"host error: {host['last_error']}") +def _announce_self_evolution(console: Any, settings: Any) -> None: + """State the self-evolution mode on the first screen, in both directions. + + Placed beside the approval-bypass notice because it is the same kind of fact: a mode + that changes what the agent may decide to do, which a user must know *before* acting + rather than discover afterwards. Announced in both states on purpose -- printing only + when enabled would make the quiet default indistinguishable from a build without the + feature, and a user who cannot tell which they have cannot reason about either. + + The world model itself is not mentioned as a mode because it is not one: it reviews + sessions and records what it learned about the environment, which changes what the + agent knows and nothing else. + """ + if getattr(settings, "evolution_enabled", False): + console.print( + "\u26a0 Self-evolution on \u2014 the agent may propose new capabilities " + "for itself; each still needs your approval", + style="bold yellow", + ) + else: + console.system( + "Self-evolution off \u2014 the agent adapts by learning, not by writing " + "new capabilities. Enable with: leap config set evolution.enabled true" + ) + + def _print_auth_setup_hint(console: Any, settings: Any) -> bool: """Render a compact first-run auth hint when no primary LLM key is configured.""" if getattr(settings, "has_llm_credentials", False): @@ -966,6 +993,7 @@ def _handle_task_control(text: str) -> bool: _render_banner() if ctx.settings.approval_bypass: console.print("\u26a0 Approval bypass active \u2014 all non-hardline actions auto-approved", style="bold yellow") + _announce_self_evolution(console, ctx.settings) _print_auth_setup_hint(console, ctx.settings) _update_status() exit_code = 0 @@ -1573,6 +1601,7 @@ def _handle_task_control(text: str) -> bool: _render_banner() if settings.approval_bypass: console.print("\u26a0 Approval bypass active \u2014 all non-hardline actions auto-approved", style="bold yellow") + _announce_self_evolution(console, settings) _print_auth_setup_hint(console, settings) _update_status() diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index c7e2d025..b55dc6f5 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Slash-command registry and dispatch. Single source of truth for all REPL commands. The registry drives: diff --git a/src/leapflow/cli/commands/relearn.py b/src/leapflow/cli/commands/relearn.py index 9762981e..e9ecd98d 100644 --- a/src/leapflow/cli/commands/relearn.py +++ b/src/leapflow/cli/commands/relearn.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Relearn subcommand — re-run learning pipeline on a saved trajectory.""" from __future__ import annotations diff --git a/src/leapflow/cli/commands/router.py b/src/leapflow/cli/commands/router.py index c1a4424b..78ec4538 100644 --- a/src/leapflow/cli/commands/router.py +++ b/src/leapflow/cli/commands/router.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Slash command routing primitives. The router keeps parsing and result semantics independent from the TUI diff --git a/src/leapflow/cli/commands/run.py b/src/leapflow/cli/commands/run.py index ac1ce14e..8d0fcdbe 100644 --- a/src/leapflow/cli/commands/run.py +++ b/src/leapflow/cli/commands/run.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Run subcommand — execute a skill by trigger match or explicit name.""" from __future__ import annotations diff --git a/src/leapflow/cli/commands/scheduler.py b/src/leapflow/cli/commands/scheduler.py index c3f8bc9b..e21e742e 100644 --- a/src/leapflow/cli/commands/scheduler.py +++ b/src/leapflow/cli/commands/scheduler.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scheduler CLI commands — arm tasks and manage scheduled execution. Provides ``leap arm`` and ``leap tasks`` subcommands for the interactive REPL. diff --git a/src/leapflow/cli/commands/skills.py b/src/leapflow/cli/commands/skills.py index 56064217..91cae7b3 100644 --- a/src/leapflow/cli/commands/skills.py +++ b/src/leapflow/cli/commands/skills.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skills subcommand — list, show, export, import, disable, delete, audit, sessions.""" from __future__ import annotations diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index e0d57a99..fe1b8e1c 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Slash-command handler implementations. Each handler follows the signature ``(ctx, console, args) -> None``. diff --git a/src/leapflow/cli/commands/teach.py b/src/leapflow/cli/commands/teach.py index 1914f7a8..998e9693 100644 --- a/src/leapflow/cli/commands/teach.py +++ b/src/leapflow/cli/commands/teach.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Teach subcommand — interactive teaching mode (record → distill).""" from __future__ import annotations diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 06a7a821..11a3e91c 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """CLI runtime context — assembles and manages the LeapFlow component graph.""" from __future__ import annotations @@ -1344,7 +1345,11 @@ def _bind_hardware_plugin(self) -> None: try: from leapflow.hardware.trust import HardwareTrustGate - plugin_trust = getattr(self, "_plugin_trust_ledger", None) + # The durable ledger, not an attribute nothing assigns. This read was + # always ``None``, so the comment above described a link that never + # existed: hardware trust events could not reach plugin governance, and + # the trust-based approval exemption had nothing to exempt against. + plugin_trust = self._process_trust_ledger() self._hardware_trust_gate = HardwareTrustGate( plugin_trust_ledger=plugin_trust, ) @@ -3079,6 +3084,155 @@ async def _persist_session_summary(self) -> None: except Exception: logger.debug("session summary persistence failed", exc_info=True) + def _capability_observation_service(self): + """The one durable observation service, shared by every producer. + + Built once and reused, because the degradation sink writes evidence here and the + world-model driver reads requirements from here. Two instances over the same file + would appear to work -- each writes and each reads -- while the classifier's + accepted-kind gate and the in-memory view diverge, so evidence admitted by one + would be invisible to the other. + """ + existing = getattr(self, "_observation_service", None) + if existing is not None: + return existing + from leapflow.learning.capability_observation import ( + CapabilityEvidenceClassifier, + CapabilityObservationService, + ) + from leapflow.storage.capability_observation_store import ( + JsonCapabilityObservationStore, + ) + + settings = self.settings + profile_layout = getattr(settings, "profile_layout", None) + if profile_layout is None: + return None + self._observation_service = CapabilityObservationService( + JsonCapabilityObservationStore(profile_layout.capability_observations_path), + classifier=CapabilityEvidenceClassifier.from_settings(settings), + ) + return self._observation_service + + def _current_environment_dict(self): + """The environment evidence is recorded against. + + Carried with each degradation so one application upgrade that breaks N + capabilities is recognisable as one transition rather than N coincidences -- the + teacher can then answer once, and usually with a rebind rather than N rebuilds. + """ + try: + from leapflow.domain.environment_fingerprint import EnvironmentFingerprint + from leapflow.domain.platform import PlatformManifest + + return EnvironmentFingerprint.from_platform_manifest( + PlatformManifest.default_darwin(), + workspace_root=str(getattr(self.settings, "workspace_root", "") or ""), + ).to_dict() + except (ImportError, AttributeError, TypeError, ValueError): + logger.debug("environment fingerprint unavailable", exc_info=True) + return {} + + def _current_affordances(self): + """App-level affordances the task environment currently offers. + + Empty when unknown, and an empty set deliberately reads as "cannot judge" rather + than "nothing is available": treating an undescribed environment as offering + nothing would mark every alternative unusable and push every verdict toward + acquire, which is the expensive direction. + """ + environment = self._current_environment_dict() + return tuple(environment.get("platform_capabilities") or ()) + + def _resolve_lifecycle_governor(self): + """Build the lifecycle governor once, lazily, with its degradation sink. + + This was ``getattr(self, "lifecycle_governor", None)`` against an attribute + nothing ever assigned, so the sweep ran with ``governor=None`` and + ``record_outcome`` was never called in production. Trust never moved, quarantine + never drained, and the degradation evidence the teacher prompt and the challenger + path were built to consume was never produced -- with the whole suite green, + because every unit test constructs the governor itself. The defect was in the + wiring, which is the one thing a unit test cannot see. + + Returns ``None`` when the profile layout is absent, the only legitimate reason to + run without governance: there is nowhere durable to record it. + + An injected ``lifecycle_governor`` still wins. That attribute was never wrong as a + substitution seam -- it was wrong as the *only* source, which is why production, + injecting nothing, ran without governance at all. + """ + injected = getattr(self, "lifecycle_governor", None) + if injected is not None: + return injected + existing = getattr(self, "_lifecycle_governor", None) + if existing is not None: + return existing + settings = self.settings + profile_layout = getattr(settings, "profile_layout", None) + if profile_layout is None: + return None + try: + from leapflow.learning.degradation_sink import build_degradation_sink + from leapflow.plugins import get_registry + from leapflow.plugins.lifecycle_governor import LifecycleGovernor + from leapflow.storage.capability_proposal_queue import ( + JsonCapabilityProposalQueue, + ) + from leapflow.storage.distilled_knowledge_store import ( + JsonDistilledKnowledgeStore, + ) + from leapflow.storage.plugin_outcome_store import JsonPluginOutcomeStore + + intake = self._capability_observation_service() + if intake is None: + return None + knowledge_store = JsonDistilledKnowledgeStore( + profile_layout.distilled_knowledge_path, + ttl_seconds=float( + getattr(settings, "distilled_knowledge_ttl_s", 0.0) or 0.0 + ), + ) + self._lifecycle_governor = LifecycleGovernor( + proposal_queue=JsonCapabilityProposalQueue( + profile_layout.capability_proposal_queue_path + ), + outcome_store=JsonPluginOutcomeStore(profile_layout.plugin_outcomes_path), + # The process ledger, hydrated from DuckDB -- not a fresh one. Letting + # the governor default to its own would give one process two divergent + # views of trust: the transitions it computed would land in a throwaway + # object while the persistent ledger the advisor and disclosure read + # stayed at DRAFT forever, so no plugin could ever earn PRODUCTION. + trust_ledger=self._process_trust_ledger(), + # Plugin health becomes capability evidence here, because the governor + # holds no registry and a capability is what a rival can be built for. + degradation_sink=build_degradation_sink( + intake=intake, + registry_provider=get_registry, + knowledge_store=knowledge_store, + environment_provider=self._current_environment_dict, + ), + ) + except (ImportError, AttributeError, OSError, RuntimeError, TypeError, ValueError): + logger.debug("lifecycle governor unavailable", exc_info=True) + return None + return self._lifecycle_governor + + def _process_trust_ledger(self): + """The durable trust ledger, or ``None`` if governance was never composed. + + Reached through the process-global advisor rather than an attribute, because that + is where ``session_factory`` puts the ledger it hydrated from DuckDB. Progressive + Trust is only progressive if the levels survive the process. + """ + try: + from leapflow.learning.plugin_advisor import get_default_advisor + + advisor = get_default_advisor() + except ImportError: + return None + return getattr(advisor, "_trust_ledger", None) if advisor is not None else None + async def _run_coevolution_sweep(self): """Cold-path governance sweep: verify effects, drain quarantine, find residue. @@ -3103,7 +3257,7 @@ async def _run_coevolution_sweep(self): # The process tracker, not a private one: the tool-outcome sink increments # that instance, so a sweep with its own would drain something nobody fed. sweep = CoevolutionSweep( - governor=getattr(self, "lifecycle_governor", None), + governor=self._resolve_lifecycle_governor(), tracker=getattr(self, "_quarantine_tracker", None) or current_quarantine_tracker(), ) @@ -3133,25 +3287,69 @@ async def _drive_world_model_evolution(self, trajectory: list, goal: str): driver never writes around that gate. """ try: - from leapflow.learning.capability_observation import ( - CapabilityEvidenceClassifier, - CapabilityObservationService, + from leapflow.learning.degradation_sink import ( + build_alternatives_provider, + build_proposal_sink, ) + from leapflow.plugins import get_registry from leapflow.learning.world_model_driver import WorldModelEvolutionDriver - from leapflow.storage.capability_observation_store import ( - JsonCapabilityObservationStore, + from leapflow.storage.capability_proposal_queue import ( + JsonCapabilityProposalQueue, + ) + from leapflow.storage.distilled_knowledge_store import ( + JsonDistilledKnowledgeStore, ) settings = self.settings profile_layout = getattr(settings, "profile_layout", None) if profile_layout is None or self.trajectory_grader is None: return None - service = CapabilityObservationService( - JsonCapabilityObservationStore(profile_layout.capability_observations_path), - classifier=CapabilityEvidenceClassifier.from_settings(settings), - ) + # The shared service, so the requirements the teacher reads are the ones + # the degradation sink wrote. + service = self._capability_observation_service() + if service is None: + return None driver = WorldModelEvolutionDriver( - teacher=self.trajectory_grader, intake=service + teacher=self.trajectory_grader, + intake=service, + # The C1 channel. Without it the cheap verdicts are graded, traced and + # discarded, so the teacher judges correctly and the next session + # repeats the same mistake. + knowledge_store=JsonDistilledKnowledgeStore( + profile_layout.distilled_knowledge_path, + ttl_seconds=float( + getattr(settings, "distilled_knowledge_ttl_s", 0.0) or 0.0 + ), + ), + # The last hop of the acquisition chain. Without it an ``acquire`` + # verdict became a requirement and stopped: resolution reported the + # capability unmet forever and the only verdict that leads to code had + # no effect. Queueing is not acting -- the queue is read by the + # dashboard and the self-management tools, which gate on approval. + # The fact the rebind/acquire choice is defined by. A teacher that + # cannot see whether another provider exists is guessing between them. + alternatives_for=build_alternatives_provider( + registry_provider=get_registry, + affordances_provider=self._current_affordances, + ), + # The last hop of the acquisition chain, and the one the switch governs. + # Queueing is still not acting -- the queue is read by the dashboard and the + # self-management tools, which gate on approval -- so this is the outermost + # of several gates rather than the only one. + # + # ``None`` when self-evolution is off, which says more than an empty queue + # would: the teacher still judges and still records that nothing installed + # can serve the capability, and that conclusion reaches the user as + # knowledge instead of as a proposal to build something. + proposal_sink=( + build_proposal_sink( + queue=JsonCapabilityProposalQueue( + profile_layout.capability_proposal_queue_path + ), + ) + if getattr(settings, "evolution_enabled", False) + else None + ), ) return await driver.drive( trajectory, diff --git a/src/leapflow/cli/helpers.py b/src/leapflow/cli/helpers.py index efff9d35..a0f7bc9d 100644 --- a/src/leapflow/cli/helpers.py +++ b/src/leapflow/cli/helpers.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shared CLI utilities — recording animation, guards, perceptual-field helpers. Progress reporters and stage configs have been relocated to diff --git a/src/leapflow/cli/tui.py b/src/leapflow/cli/tui.py index 10808e7b..3d5175d1 100644 --- a/src/leapflow/cli/tui.py +++ b/src/leapflow/cli/tui.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """TUI rendering primitives for the interactive REPL. Pre-renders an input frame (input line, status bar) then positions the cursor diff --git a/src/leapflow/cli/tui_app/__init__.py b/src/leapflow/cli/tui_app/__init__.py index d3101831..d7f67d0f 100644 --- a/src/leapflow/cli/tui_app/__init__.py +++ b/src/leapflow/cli/tui_app/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapFlow Terminal UI — hybrid Application + Rich architecture. Built on ``prompt_toolkit`` (Application layout, fixed input, key bindings) diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index 62f0d82c..cef024de 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Application-based TUI controller — the hybrid architecture core. Combines prompt_toolkit's Application (persistent layout, fixed input, diff --git a/src/leapflow/cli/tui_app/approval_modal.py b/src/leapflow/cli/tui_app/approval_modal.py index 5b8bcf0f..f7777643 100644 --- a/src/leapflow/cli/tui_app/approval_modal.py +++ b/src/leapflow/cli/tui_app/approval_modal.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Prompt-toolkit native approval modal for LeapFlow TUI. Renders a bordered panel with action summary, detail, risk reason, diff --git a/src/leapflow/cli/tui_app/command.py b/src/leapflow/cli/tui_app/command.py index 82c852aa..3bf72497 100644 --- a/src/leapflow/cli/tui_app/command.py +++ b/src/leapflow/cli/tui_app/command.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Command lifecycle primitives for the interactive TUI. The module is intentionally UI-framework agnostic: it models submitted user diff --git a/src/leapflow/cli/tui_app/console.py b/src/leapflow/cli/tui_app/console.py index fa1c43fc..767fc3a9 100644 --- a/src/leapflow/cli/tui_app/console.py +++ b/src/leapflow/cli/tui_app/console.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Rich console wrapper — the single output surface for the TUI. Centralizes all visual output: markdown rendering, code highlighting, @@ -479,8 +480,15 @@ def session_info( cwd: str = "", skill_count: int = 0, session_id: str = "", + self_evolution: bool | None = None, ) -> None: - """Display compact session information after the banner.""" + """Display compact session information after the banner. + + ``self_evolution`` is shown on the first screen rather than left to ``/config`` + because it is the one mode that changes what the agent may decide to do to itself. + A user should learn that it is off from the line they already read, not by going + looking -- and, when it is on, should have been told so before it acts. + """ info_parts: list[str] = [] if model: info_parts.append(f"model: {model}") @@ -491,6 +499,12 @@ def session_info( info_parts.append(f"cwd: {short_cwd}") if skill_count > 0: info_parts.append(f"skills: {skill_count}") + if self_evolution is not None: + # Named in both states. Showing it only when enabled would make the quiet + # default indistinguishable from a build that does not have the feature. + info_parts.append( + f"self-evolution: {'on' if self_evolution else 'off'}" + ) if info_parts: self._console.print( diff --git a/src/leapflow/cli/tui_app/input.py b/src/leapflow/cli/tui_app/input.py index 040bcb1e..fd253d99 100644 --- a/src/leapflow/cli/tui_app/input.py +++ b/src/leapflow/cli/tui_app/input.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Slash-command completion for the TUI input area. Provides interactive slash-command suggestions for the TextArea widget. diff --git a/src/leapflow/cli/tui_app/paste.py b/src/leapflow/cli/tui_app/paste.py index 65be2ac5..43bd4133 100644 --- a/src/leapflow/cli/tui_app/paste.py +++ b/src/leapflow/cli/tui_app/paste.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Safe paste handling for the TUI input buffer. This module keeps high-risk pasted content out of the visible prompt_toolkit diff --git a/src/leapflow/cli/tui_app/session_summary.py b/src/leapflow/cli/tui_app/session_summary.py index bc9f7ab0..46122d61 100644 --- a/src/leapflow/cli/tui_app/session_summary.py +++ b/src/leapflow/cli/tui_app/session_summary.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Exit summary helpers for interactive TUI sessions.""" from __future__ import annotations diff --git a/src/leapflow/cli/tui_app/status.py b/src/leapflow/cli/tui_app/status.py index 27f0cd0e..e1ca5cbc 100644 --- a/src/leapflow/cli/tui_app/status.py +++ b/src/leapflow/cli/tui_app/status.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Status bar for the Application layout. Hermes-style single-line status rendered via ``FormattedTextControl``:: diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index 5793f2a0..85820ff2 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Streaming LLM output renderer — Application-compatible. Accumulates streaming token deltas, tracks tool call state, and diff --git a/src/leapflow/cli/tui_app/theme.py b/src/leapflow/cli/tui_app/theme.py index 05d0e458..bada4ab2 100644 --- a/src/leapflow/cli/tui_app/theme.py +++ b/src/leapflow/cli/tui_app/theme.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Adaptive theming with contrast-aware input colors. Detects terminal background color via conservative environment heuristics and diff --git a/src/leapflow/config.py b/src/leapflow/config.py index b1d58c33..1bb6e675 100644 --- a/src/leapflow/config.py +++ b/src/leapflow/config.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Configuration loading from structured YAML with environment overrides. Loading priority (highest wins): @@ -365,6 +366,23 @@ class Settings: # kind still traverses the unchanged deterministic chain -- resolution, risk, # approval, validation, trust -- so widening this set adds a *trigger*, never # a permission. + # The self-evolution switch, and the only one a user should need to find. + # + # The world model runs regardless: it grades every episode, distils what it learned + # about the environment into the next session's context, and recommends which installed + # provider to prefer. None of that writes code, asks for approval, or changes what the + # agent is able to do -- it changes what the agent *knows*, and switching it off would + # cost adaptation for no reduction in risk. + # + # This switch governs the one branch that does write code: an ``acquire`` verdict + # becoming a queued proposal for a new plugin. Off by default because acquiring a + # capability is the most expensive and least reversible thing the system can decide to + # do, and because it should be a deliberate choice rather than something a user + # discovers after it has already happened. Queued is still not built -- generation, + # validation, approval, sandboxing and trust all remain in front of it -- so this is + # the outermost of several gates, not the only one. + evolution_enabled: bool = False + accepted_evidence_kinds: tuple[str, ...] = () # Requirement origins permitted to drive an *acquisition*. Empty means # unrestricted (shipped behaviour): any origin may. Setting it to @@ -372,6 +390,26 @@ class Settings: # driver is the world model" -- other origins keep being recorded and resolved, # but can no longer authorise acquiring new code. evolution_authorising_origins: tuple[str, ...] = () + # Which registered policy chooses among admissible tool candidates. ``greedy`` + # is the shipped default and reproduces the selection made before the policy + # seam existed: highest weighted score, stable tie-break. + # + # A flat typed key rather than a nested params dict because every durable + # setting must be discoverable through ``leap config`` -- a dict would become a + # YAML-only knob. Built-in policies that need parameters declare them as their + # own flat ``selection_*`` keys; a third-party policy registered through the + # entry point group configures itself, since it cannot add fields here. + selection_policy: str = "greedy" + # How long a distilled fact about the environment stays disclosed. An assertion + # about a changing world is only true for a while, and stale knowledge misleads + # rather than merely going unused -- the acting agent cannot tell a current fact + # from one that expired three upgrades ago. Configurable because the right horizon + # depends on how fast the environment moves; 0 disables expiry. + distilled_knowledge_ttl_s: float = 604800.0 + #: Cap on how many distilled facts reach the prompt, so the channel meant to + #: improve context cannot come to dominate it. + distilled_knowledge_limit: int = 12 + replay_on_session_end: bool = True prediction_structural_blend: float = 0.4 prediction_semantic_blend: float = 0.6 @@ -931,6 +969,7 @@ def _build_settings_from_env( replay_budget = int(os.getenv("LEAPFLOW_REPLAY_BUDGET", "3")) grading_budget = int(os.getenv("LEAPFLOW_GRADING_BUDGET", "5")) distillation_budget = int(os.getenv("LEAPFLOW_DISTILLATION_BUDGET", "2")) + evolution_enabled = _bool("LEAPFLOW_EVOLUTION_ENABLED", "false") accepted_evidence_kinds = tuple( kind.strip() for kind in os.getenv("LEAPFLOW_ACCEPTED_EVIDENCE_KINDS", "").split(",") @@ -941,7 +980,12 @@ def _build_settings_from_env( for origin in os.getenv("LEAPFLOW_EVOLUTION_AUTHORISING_ORIGINS", "").split(",") if origin.strip() ) + selection_policy = os.getenv("LEAPFLOW_SELECTION_POLICY", "greedy").strip() or "greedy" replay_on_session_end = _bool("LEAPFLOW_REPLAY_ON_SESSION_END", "true") + distilled_knowledge_ttl_s = float( + os.getenv("LEAPFLOW_DISTILLED_KNOWLEDGE_TTL_S", "604800") + ) + distilled_knowledge_limit = int(os.getenv("LEAPFLOW_DISTILLED_KNOWLEDGE_LIMIT", "12")) prediction_structural_blend = float(os.getenv("LEAPFLOW_PREDICTION_STRUCTURAL_BLEND", "0.4")) prediction_semantic_blend = float(os.getenv("LEAPFLOW_PREDICTION_SEMANTIC_BLEND", "0.6")) prediction_semantic_threshold = float(os.getenv("LEAPFLOW_PREDICTION_SEMANTIC_THRESHOLD", "0.1")) @@ -1362,9 +1406,13 @@ def _tuple_env(key: str, default: tuple) -> tuple: replay_budget=replay_budget, grading_budget=grading_budget, distillation_budget=distillation_budget, + evolution_enabled=evolution_enabled, accepted_evidence_kinds=accepted_evidence_kinds, evolution_authorising_origins=evolution_authorising_origins, + selection_policy=selection_policy, replay_on_session_end=replay_on_session_end, + distilled_knowledge_ttl_s=distilled_knowledge_ttl_s, + distilled_knowledge_limit=distilled_knowledge_limit, prediction_structural_blend=prediction_structural_blend, prediction_semantic_blend=prediction_semantic_blend, prediction_semantic_threshold=prediction_semantic_threshold, diff --git a/src/leapflow/config_loader.py b/src/leapflow/config_loader.py index 1978b2af..934846bb 100644 --- a/src/leapflow/config_loader.py +++ b/src/leapflow/config_loader.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Structured YAML configuration loader for LeapFlow. The loader treats YAML files as the long-lived configuration source and process diff --git a/src/leapflow/config_service.py b/src/leapflow/config_service.py index a70cfec0..8605590d 100644 --- a/src/leapflow/config_service.py +++ b/src/leapflow/config_service.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """User-facing configuration control plane for LeapFlow.""" from __future__ import annotations @@ -327,9 +328,31 @@ class ConfigSnapshot: "plugins.dsh_max_message_bytes": "Maximum bytes in one DSH worker NDJSON protocol message; requires daemon restart.", "plugins.dsh_max_stderr_bytes": "Bounded diagnostic stderr tail retained from one DSH worker; requires daemon restart.", "plugins.dsh_max_memory_mb": "V8 old-space ceiling in megabytes for each DSH worker process; requires daemon restart.", + "selection.policy": ( + "Which registered policy chooses among tools that provide the same capability. " + "greedy, the only built-in, takes the highest-scoring candidate. Built-in tools " + "provide one capability each, so a policy has something to choose between only " + "once self-evolution has generated a competing tool -- which is why the learning " + "policies that once shipped here were removed after measurement, and why a " + "third-party policy can register through the entry point group when that changes." + ), + "evolution.enabled": ( + "Whether the agent may propose acquiring a NEW capability for itself. Off by " + "default. The world model runs either way: it reviews every session, records what " + "it learned about your environment for the next one, and recommends which " + "installed tool to prefer -- none of which writes code. This switch governs the " + "one branch that does: turning a 'nothing installed can do this' conclusion into a " + "queued proposal for a new plugin. Queued is not built; generation, validation, " + "approval, sandboxing and trust all still apply, and every plugin change asks for " + "your approval individually." + ), } _SECTION_CATEGORIES = { + # Its own category rather than folded into Learning or Plugins: this is the switch a + # user is most likely to go looking for, and burying it among tuning knobs would make + # the most consequential setting the hardest to find. + "evolution": "Self-Evolution", "llm": "LLM Provider", "vlm": "Perception", "memory": "Memory", @@ -388,8 +411,28 @@ class ConfigSnapshot: "web.transport": "auto|httpx|curl", "web.extractor": "auto|stdlib", "web.private_targets": "approval|deny|allow", + # Callable rather than a literal: the valid ids come from the live policy + # registry, which a third-party package can add to through an entry point. A + # hardcoded enumeration here would silently omit every such policy and would + # need editing whenever a built-in is added. + "selection.policy": lambda: _registered_selection_policies(), } + +def _registered_selection_policies() -> str: + """The policy ids currently registered, as a hint. + + Degrades to a generic note rather than logging: the only consequence of failure is + a less specific hint, and this module deliberately carries no logger. + """ + try: + from leapflow.plugins.selection_policy_registry import get_selection_policy_registry + + ids = "|".join(d.policy_id for d in get_selection_policy_registry().describe()) + except Exception: # noqa: BLE001 - a missing hint must not break the catalog + return "a registered selection policy id" + return ids or "a registered selection policy id" + _PARTIAL_RELOAD_SECTIONS = frozenset({"runtime", "mock", "gateway", "hub", "scheduler", "observer", "cua", "use", "dashboard"}) _RESTART_REQUIRED_SECTIONS = frozenset({"daemon", "plugins", "hardware", "mcp"}) @@ -720,11 +763,16 @@ def _with_metadata(spec: ConfigFieldSpec) -> ConfigFieldSpec: reload_semantics = "partial" else: reload_semantics = "yes" + hint = _VALUE_HINTS.get(spec.key, None) + if hint is None: + hint = _default_value_hint(spec) + elif callable(hint): + hint = hint() return replace( spec, category=_category_for_spec(spec), description=_FIELD_DESCRIPTIONS.get(spec.key, _default_description(spec.key)), - value_hint=_VALUE_HINTS.get(spec.key, _default_value_hint(spec)), + value_hint=str(hint), hot_reload=reload_semantics, examples=_examples_for_key(spec.key), ) diff --git a/src/leapflow/copilot/__init__.py b/src/leapflow/copilot/__init__.py index 1803814f..e7535138 100644 --- a/src/leapflow/copilot/__init__.py +++ b/src/leapflow/copilot/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Workflow Copilot — context-triggered workflow auto-completion engine.""" from leapflow.copilot.adapters import ( diff --git a/src/leapflow/copilot/adapters.py b/src/leapflow/copilot/adapters.py index b00ea0f9..0415c155 100644 --- a/src/leapflow/copilot/adapters.py +++ b/src/leapflow/copilot/adapters.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Memory Bridge Adapters — connect Copilot prediction layers to the Memory system. Each adapter implements a Copilot Protocol using a Memory provider as its backend, diff --git a/src/leapflow/copilot/config.py b/src/leapflow/copilot/config.py index a23304e3..743e1022 100644 --- a/src/leapflow/copilot/config.py +++ b/src/leapflow/copilot/config.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Copilot configuration model — all tuneable parameters in one place. Every threshold, toggle, and budget is exposed here so that runtime behaviour diff --git a/src/leapflow/copilot/context.py b/src/leapflow/copilot/context.py index 2e49c184..0901296e 100644 --- a/src/leapflow/copilot/context.py +++ b/src/leapflow/copilot/context.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Incremental context encoder and EventBus bridge for Workflow Copilot. Receives raw SystemEvent streams and maintains an up-to-date ContextState diff --git a/src/leapflow/copilot/degradation.py b/src/leapflow/copilot/degradation.py index 1cc421cc..7a245d18 100644 --- a/src/leapflow/copilot/degradation.py +++ b/src/leapflow/copilot/degradation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Graceful degradation policy for the Workflow Copilot. Monitors system resource usage and automatically disables higher-cost diff --git a/src/leapflow/copilot/engine.py b/src/leapflow/copilot/engine.py index b6905469..ca9f1bfe 100644 --- a/src/leapflow/copilot/engine.py +++ b/src/leapflow/copilot/engine.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """PredictionEngine — multi-layer cascade prediction scheduler. Orchestrates all registered PredictorLayer instances, executing them according diff --git a/src/leapflow/copilot/feedback.py b/src/leapflow/copilot/feedback.py index e5aa316f..2e0d002b 100644 --- a/src/leapflow/copilot/feedback.py +++ b/src/leapflow/copilot/feedback.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Feedback collection and evolution loop for the Workflow Copilot. Captures user reactions (accept / ignore / correct / reject) to displayed diff --git a/src/leapflow/copilot/idle.py b/src/leapflow/copilot/idle.py index 19f70400..77fd25e2 100644 --- a/src/leapflow/copilot/idle.py +++ b/src/leapflow/copilot/idle.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Idle (pause) detection for the Workflow Copilot. Identifies natural user pauses between operations and triggers predictive diff --git a/src/leapflow/copilot/os_renderer.py b/src/leapflow/copilot/os_renderer.py index b3691863..6812f782 100644 --- a/src/leapflow/copilot/os_renderer.py +++ b/src/leapflow/copilot/os_renderer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """OS-native notification renderer for Copilot suggestions. Platform-specific implementations: diff --git a/src/leapflow/copilot/pipeline.py b/src/leapflow/copilot/pipeline.py index d2280ac0..27a1d404 100644 --- a/src/leapflow/copilot/pipeline.py +++ b/src/leapflow/copilot/pipeline.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """SpeculativePipeline — proactive prediction cache with tiered warming. Implements the "predict-before-idle" strategy: when an action is observed, diff --git a/src/leapflow/copilot/predictors/__init__.py b/src/leapflow/copilot/predictors/__init__.py index 7531d5cc..34b93364 100644 --- a/src/leapflow/copilot/predictors/__init__.py +++ b/src/leapflow/copilot/predictors/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Multi-layer prediction engine components.""" from leapflow.copilot.predictors.l0_hash import L0HashPredictor diff --git a/src/leapflow/copilot/predictors/l0_hash.py b/src/leapflow/copilot/predictors/l0_hash.py index a08af4ce..402baf0e 100644 --- a/src/leapflow/copilot/predictors/l0_hash.py +++ b/src/leapflow/copilot/predictors/l0_hash.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """L0 Exact-Match Predictor — O(1) context-hash lookup. Provides the fastest prediction path by matching the current ContextState hash diff --git a/src/leapflow/copilot/predictors/l1_markov.py b/src/leapflow/copilot/predictors/l1_markov.py index 6045b2e1..576106de 100644 --- a/src/leapflow/copilot/predictors/l1_markov.py +++ b/src/leapflow/copilot/predictors/l1_markov.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """L1 Markov Sequence Predictor — N-gram transition probability model. Maintains a transition count matrix over action sequences (N-gram keys). diff --git a/src/leapflow/copilot/predictors/l2_embed.py b/src/leapflow/copilot/predictors/l2_embed.py index f0ca2da4..7245eacb 100644 --- a/src/leapflow/copilot/predictors/l2_embed.py +++ b/src/leapflow/copilot/predictors/l2_embed.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """L2 Embedding Retrieval Predictor — semantic similarity search. Retrieves historically similar contexts via vector embedding nearest-neighbour diff --git a/src/leapflow/copilot/predictors/l3_llm.py b/src/leapflow/copilot/predictors/l3_llm.py index 53ce123d..f179f4bc 100644 --- a/src/leapflow/copilot/predictors/l3_llm.py +++ b/src/leapflow/copilot/predictors/l3_llm.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """L3 LLM Reasoning Predictor — deep inference with RAG context. Uses a large-language model to generate action predictions for complex diff --git a/src/leapflow/copilot/renderer.py b/src/leapflow/copilot/renderer.py index e5c80236..26287d0d 100644 --- a/src/leapflow/copilot/renderer.py +++ b/src/leapflow/copilot/renderer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Suggestion rendering and display gating for the Workflow Copilot. Implements the "rather not show than show late" principle: diff --git a/src/leapflow/copilot/types.py b/src/leapflow/copilot/types.py index c006b5dc..b8303bf8 100644 --- a/src/leapflow/copilot/types.py +++ b/src/leapflow/copilot/types.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Core protocol and data-type definitions for the Workflow Copilot module. Defines the shared vocabulary of immutable data objects and structural diff --git a/src/leapflow/daemon/__init__.py b/src/leapflow/daemon/__init__.py index f2be332d..56719e2c 100644 --- a/src/leapflow/daemon/__init__.py +++ b/src/leapflow/daemon/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapFlow daemon (leapd) — centralized process for DuckDB + runtime. The daemon architecture follows the "single process owns all mutable state" diff --git a/src/leapflow/daemon/_service_helpers.py b/src/leapflow/daemon/_service_helpers.py index 6d395f21..178a5309 100644 --- a/src/leapflow/daemon/_service_helpers.py +++ b/src/leapflow/daemon/_service_helpers.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Pure utility functions extracted from service.py to keep the orchestrator slim.""" from __future__ import annotations diff --git a/src/leapflow/daemon/_transport.py b/src/leapflow/daemon/_transport.py index 4c8bac5b..c1a5f81f 100644 --- a/src/leapflow/daemon/_transport.py +++ b/src/leapflow/daemon/_transport.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cross-platform daemon IPC transport. Unix (macOS/Linux): Unix Domain Socket via asyncio.start_unix_server / open_unix_connection diff --git a/src/leapflow/daemon/approval_coordinator.py b/src/leapflow/daemon/approval_coordinator.py index 7c2c0a79..6e82eb4b 100644 --- a/src/leapflow/daemon/approval_coordinator.py +++ b/src/leapflow/daemon/approval_coordinator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Approval lifecycle coordinator extracted from RuntimeLeapService.""" from __future__ import annotations diff --git a/src/leapflow/daemon/approval_route.py b/src/leapflow/daemon/approval_route.py index 0498a506..657a6a10 100644 --- a/src/leapflow/daemon/approval_route.py +++ b/src/leapflow/daemon/approval_route.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shared ContextVar for per-turn approval routing. Extracted to its own module to avoid circular dependency between diff --git a/src/leapflow/daemon/client.py b/src/leapflow/daemon/client.py index 9462a7ab..93095770 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Thin client for connecting LeapFlow CLI processes to leapd.""" from __future__ import annotations diff --git a/src/leapflow/daemon/lease.py b/src/leapflow/daemon/lease.py index f94b8978..b44d9885 100644 --- a/src/leapflow/daemon/lease.py +++ b/src/leapflow/daemon/lease.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Client lease files for leapd multi-client lifecycle tracking.""" from __future__ import annotations diff --git a/src/leapflow/daemon/lifecycle.py b/src/leapflow/daemon/lifecycle.py index f94794fe..0b0e7c7a 100644 --- a/src/leapflow/daemon/lifecycle.py +++ b/src/leapflow/daemon/lifecycle.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Daemon lifecycle management — PID files, lock files, health checks. Handles the leapd daemon lifecycle: diff --git a/src/leapflow/daemon/monitor_coordinator.py b/src/leapflow/daemon/monitor_coordinator.py index 9f78484a..5945b5d2 100644 --- a/src/leapflow/daemon/monitor_coordinator.py +++ b/src/leapflow/daemon/monitor_coordinator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Manages the daemon-hosted monitor runtime (watches, findings, tickers). Extracted from service.py (Phase 2.2) to keep RuntimeLeapService focused on diff --git a/src/leapflow/daemon/notifications.py b/src/leapflow/daemon/notifications.py index 94f08283..3a080565 100644 --- a/src/leapflow/daemon/notifications.py +++ b/src/leapflow/daemon/notifications.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Daemon notification bus — push events from background tasks to connected TUI clients. Architecture: diff --git a/src/leapflow/daemon/protocol.py b/src/leapflow/daemon/protocol.py index 4a317d3f..bd81e417 100644 --- a/src/leapflow/daemon/protocol.py +++ b/src/leapflow/daemon/protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """JSON-RPC 2.0 protocol types and LeapService interface for leapd. The protocol layer defines: diff --git a/src/leapflow/daemon/reentry_coordinator.py b/src/leapflow/daemon/reentry_coordinator.py index be2427be..b6130cda 100644 --- a/src/leapflow/daemon/reentry_coordinator.py +++ b/src/leapflow/daemon/reentry_coordinator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Manages the reentry driver lifecycle: background tick loop, gateway observation. Extracted from service.py (Phase 2.4) to keep RuntimeLeapService focused on diff --git a/src/leapflow/daemon/server.py b/src/leapflow/daemon/server.py index f2388fc8..d72f8557 100644 --- a/src/leapflow/daemon/server.py +++ b/src/leapflow/daemon/server.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unix socket JSON-RPC server for leapd.""" from __future__ import annotations diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index ffd50286..39727bc7 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Runtime-backed LeapService implementation for leapd. This module is the lightweight orchestrator: it assembles coordinators, manages diff --git a/src/leapflow/daemon/session_coordinator.py b/src/leapflow/daemon/session_coordinator.py index 644454ee..24fc906f 100644 --- a/src/leapflow/daemon/session_coordinator.py +++ b/src/leapflow/daemon/session_coordinator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Manages session lifecycle: create, resume, history, analysis, artifacts. Extracted from service.py (Phase 2.3) to keep RuntimeLeapService focused on diff --git a/src/leapflow/daemon/session_registry.py b/src/leapflow/daemon/session_registry.py index 1c71ee88..b57bd63b 100644 --- a/src/leapflow/daemon/session_registry.py +++ b/src/leapflow/daemon/session_registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Session-scoped execution registry for the daemon (Stage 3, P3-2a). Maps a ``session_id`` to a :class:`SessionExecutionContext` — the per-session diff --git a/src/leapflow/daemon/turn_admission.py b/src/leapflow/daemon/turn_admission.py index b4889eac..15250495 100644 --- a/src/leapflow/daemon/turn_admission.py +++ b/src/leapflow/daemon/turn_admission.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Turn admission control for bounded concurrent execution (Stage 3, P3-4). ``TurnAdmission`` bounds how many agent turns run concurrently (up to N) while diff --git a/src/leapflow/dashboard/__init__.py b/src/leapflow/dashboard/__init__.py index 49930fa2..51c193cf 100644 --- a/src/leapflow/dashboard/__init__.py +++ b/src/leapflow/dashboard/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Dashboard subsystem: declarative Server-Driven UI (SDUI) for monitoring. This package owns the domain-neutral view layer: diff --git a/src/leapflow/dashboard/hub.py b/src/leapflow/dashboard/hub.py index 8ebda02c..1912e7ab 100644 --- a/src/leapflow/dashboard/hub.py +++ b/src/leapflow/dashboard/hub.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """ViewHub: fan out daemon monitor events to browser WebSocket subscribers. The dashboard server holds a single subscription to the daemon NotificationBus diff --git a/src/leapflow/dashboard/intent.py b/src/leapflow/dashboard/intent.py index b755ca20..43e9c0ef 100644 --- a/src/leapflow/dashboard/intent.py +++ b/src/leapflow/dashboard/intent.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DashboardIntent: the single normalized request behind ``/board`` and the tool. The **template** is the primary view dimension (a rendering lens). Most templates diff --git a/src/leapflow/dashboard/launcher.py b/src/leapflow/dashboard/launcher.py index 153f2099..a49df706 100644 --- a/src/leapflow/dashboard/launcher.py +++ b/src/leapflow/dashboard/launcher.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Dashboard launcher: token/URL/state helpers, browser open, and server spawn. The dashboard runs as a separate view-client process (like the TUI). This module diff --git a/src/leapflow/dashboard/revision.py b/src/leapflow/dashboard/revision.py index b0ccb101..35425471 100644 --- a/src/leapflow/dashboard/revision.py +++ b/src/leapflow/dashboard/revision.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Content revision for one atomic LeapBoard server generation. A Board process imports Python once but historically read YAML/JS/CSS from disk on every diff --git a/src/leapflow/dashboard/server.py b/src/leapflow/dashboard/server.py index ac716f21..4babc53d 100644 --- a/src/leapflow/dashboard/server.py +++ b/src/leapflow/dashboard/server.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Local dashboard web server (optional aiohttp transport, view-client process). Holds one upstream subscription to the daemon (via DaemonClient) and fans out diff --git a/src/leapflow/dashboard/service.py b/src/leapflow/dashboard/service.py index 6f9d6002..507e8620 100644 --- a/src/leapflow/dashboard/service.py +++ b/src/leapflow/dashboard/service.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DashboardViewBuilder: turn a DashboardIntent + live data into a ViewSpec. The builder is transport-agnostic: it reads data through a small diff --git a/src/leapflow/dashboard/static/app.js b/src/leapflow/dashboard/static/app.js index c09cb855..6992c1da 100644 --- a/src/leapflow/dashboard/static/app.js +++ b/src/leapflow/dashboard/static/app.js @@ -227,11 +227,11 @@ // extended: five of seven templates shipped untranslated in every language, and // the i18n test only checked signal keys, so nothing failed. Keyed by the English // source string, so an untranslated key still renders readable English. - zh: {"> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **回归:已闭合的缺口再次复发。** 一次看起来成功的演进并未站住。这是本看板上唯一需要立即处理的发现。", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **仅快照。** 目前尚无可重建的因果历史,因此时间线是「缺席」而非「空白」。原因由管道贯通度中的 `策略决策` 一行说明;实时快照与贯通度表本身不受影响。", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **部分插件因内部缺陷被冻结。** 冻结的插件仍报告 `DRAFT`,而信任维度只做「打分」,因此若未同时注销,它仍可被选中——请查看 `可被选中` 列。", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **验证层级:L2(声明式适配)。** 观测被退役,只意味着某个候选**声明**自己提供该能力,并不意味着该能力被观测到确实生效。效果验证(L3)尚未接线,因此本看板上的任何闭合都不应被读作「已证实」。", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> 需要至少完成一个观测周期才会有内容。若持续为空,请检查调度器是否启用、`framework-evolution` watch 是否已 armed 且未静音。", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> 当一次环境观测导向一次能力决策时,才会写下一条剧集。目前尚无记录——这既可能是系统本就安静,也可能是管道更早就断了:**管道**页签会指出它断在哪一段,以及什么能解除阻塞。", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> 目前没有任何机制自动回收它们。每一个都占着一个工具名、出现在能力列表里,却不可被选中——注册表朝着没有任何需求能用的方向增长。", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> 这些提议未进入任何管道,因此不会出现在任何决策记录或观测中。是否准入是一项配置选择。", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "比值低于 1.0 表示采样循环未能维持其声明的节奏。", "Acquisition authority": "获取授权", "Acquisition lifecycle": "获取生命周期", "Action": "动作", "After": "变更后", "An unverified declaration has its writable channels demoted to read-only.": "未核验的声明,其可写通道会被降级为只读。", "Approval": "审批", "Autonomous governance": "自主治理", "Autonomy": "自主级别", "Before": "变更前", "CANDIDATE": "候选级", "Calibrated at": "校准时间", "Calibration health": "校准健康度", "Calls": "调用次数", "Calls (decisions)": "观点(决策)", "Candlestick": "K 线", "Capability": "能力", "Capability adaptation": "能力适配", "Capability observations": "能力观测", "Capability ownership": "能力归属", "Capability topology": "能力拓扑", "Change": "变化", "Channel": "通道", "Channels": "通道数", "Channels that have never been calibrated or whose calibration has expired are shown first.": "从未校准或校准已过期的通道排在最前。", "Command": "命令", "Commanded versus observed, best tracking first": "命令值与实测值对比,跟随最好者在前", "Composition": "组成", "Concerns (open questions)": "关切(待答问题)", "Confidence": "置信度", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "统计所有绘制通道。“接近”指处于声明边界的 5% 以内。", "Cycles run": "已运行周期", "DRAFT": "草稿级", "Days since": "距今天数", "Decision": "决策", "Decisions read as calls; action items as the execution checklist.": "决策即观点,行动项即执行清单。", "Declared Hz": "声明频率 (Hz)", "Desk brief": "交易台简报", "Device": "设备", "Dropped samples": "丢弃的样本", "Each row names one blocked segment and the change that would unblock it.": "每一行指出一个受阻环节,以及能解除阻塞的那项变更。", "Effect verification (L3)": "效果验证(L3)", "Entities as references, and recommended next prompts to advance the work.": "实体作为参考,并给出推进工作的后续追问。", "Entities in play and the open risks still to resolve.": "涉及的实体,以及尚未解决的敞口风险。", "Envelope, rate, staleness and quality observations · newest first": "包络、速率、失联与质量观测 · 最新在前", "Environment": "环境", "Environment to framework": "环境 → 框架", "Environment, selected plugin tools, and orchestration order.": "环境、已选插件工具及编排顺序。", "Error rate": "错误率", "Events paced out": "被配速抑制的事件", "Ever used": "是否用过", "Evidence": "证据", "Evidence admission": "证据准入", "Evolution": "演进", "Evolution timeline": "演进时间线", "Executable": "可执行", "Execution checklist": "执行清单", "Extracted from this session's tool/file output (not model-generated).": "数据来自本次会话的工具/文件产物(非模型生成)。", "Failures": "失败次数", "Fiber": "Fiber 状态", "Fiber state changes since the previous cycle, including load retries.": "自上一周期以来的 Fiber 状态变化,含加载重试。", "Finance lens": "金融视图", "Follow-ups": "后续事项", "Framework change": "框架变更", "Framework changes as they happened, from runtime probes.": "来自运行时探针的框架变更实况。", "Framework evolution": "框架演进", "Framework size and how much of the evolution pipeline shows runtime evidence.": "框架规模,以及演进管道中有多少环节呈现运行时证据。", "From": "从", "Frozen plugins": "已冻结插件", "Gap closure": "缺口闭合", "Halt": "可急停", "How closures are verified": "闭合是如何验证的", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "框架中有多少是它自己长出来的,以及演进管道中有多少环节呈现运行时证据。", "How often each window sat inside, near, or outside its declared limits": "各窗口处于声明限值内、接近边界或越界的频次", "Inquiry brief": "研究简报", "Insights carded as evidence, capped for fast review.": "洞察以证据卡呈现,数量受限以便快速浏览。", "Instruments & counterparties": "标的与交易对手", "Kept": "保留", "Latest capability decision": "最新能力决策", "Lifecycle records": "生命周期记录", "Lifecycle timeline": "生命周期时间线", "Lifecycle transitions": "生命周期迁移", "Line of inquiry": "研究主线", "Live activity": "实时动态", "Location": "位置", "Loop phase": "循环阶段", "Mean of each downsample window. Declared limits are listed per channel below.": "每个降采样窗口的均值。各通道的声明限值见下方。", "Model's reasoning": "模型的推理", "Mutation": "变更", "Narrative": "叙事", "Narrative pulse": "叙事脉搏", "Needs attention": "需要关注", "Next recal due": "下次校准期限", "Next step": "下一步", "No causal history yet": "尚无因果历史", "Normalized error": "归一化误差", "Normalized error is the residual as a share of the channel's declared span.": "归一化误差是残差占该通道声明量程的比例。", "Not yet observed": "尚未观测", "Nothing has driven a framework change, so there is no episode to narrate.": "尚无任何事驱动过框架变更,因此没有可讲述的剧集。", "OHLC extracted from captured session market data.": "OHLC 提取自本次会话捕获的行情数据。", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "观测待办、提案状态、策略决策与生命周期结果。", "Observations": "观测数", "Observed Hz": "实测频率 (Hz)", "Observed rate against declared rate": "实测速率与声明速率对比", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "单一全局命名空间,先注册者胜。挑战者会被记录,绝不静默丢弃。", "Open": "已连接", "Open risks": "敞口风险", "Open/high/low/close from captured tool output.": "开/高/低/收,来自捕获的工具输出。", "Origin": "来源", "Outcome": "结果", "PRODUCTION": "生产级", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "逐条剧集:触发源、决策、变更,以及缺口是否闭合。", "Per-channel calibration state, freshness, and residual correction": "各通道的校准状态、时效性与残差校正", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "逐段运行时证据。模块存在并不等于有任何代码调用它。", "Pipeline": "管道", "Pipeline evidence": "管道证据", "Pipeline reachability": "管道贯通度", "Plan": "计划", "Plan steps": "计划步骤", "Plugin": "插件", "Plugin roster and trust": "插件名册与信任", "Plugins": "插件数", "Plugins by origin": "按来源分布的插件", "Plugins by trust class": "按信任等级分布的插件", "Policy": "策略", "Policy decisions": "策略决策", "Positions & actions": "持仓与操作", "Posture": "态势", "Price action": "价格行为", "Proposal": "提案", "Proposal status": "提案状态", "Proposed, not admitted": "已提议,未准入", "Pulse": "脉搏", "Quarantine feed": "隔离进料", "Ratio": "比值", "Read live from the registry and trust ledger every cycle.": "每个周期从注册表与信任账本实时读取。", "Recent episodes": "近期剧集", "Reclaim candidates": "可回收候选", "Reclaimable": "可回收", "References & follow-ups": "参考与后续", "References (entities)": "参考(实体)", "Registry": "注册表", "Registry delta": "注册表变化", "Registry version": "注册表版本", "Regressions": "回归", "Rejected": "被拒", "Representative observations, capped for quick scanning.": "代表性观察,数量受限以便快速浏览。", "Requirements": "能力需求", "Research lens": "研究视图", "Residual": "残差", "Runtime evidence": "运行时证据", "Sampled history per channel, newest on the right": "按通道的采样历史,最新在右侧", "Segment": "管道段", "Segments by status": "按状态分布的管道段", "Selectable": "可被选中", "Selection delta": "选择变化", "Self-acquired": "自获取", "Self-acquired plugins that are registered but unselectable or never once used.": "已注册但不可被选中、或从未被使用过的自获取插件。", "Sentiment lens": "情绪视图", "Series": "序列", "Session analysis": "会话分析", "Signal strength": "信号强度", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "表明某处长错了、或被扣下未放行的信号。无论打开哪个页签都会显示。", "Skipped slots": "跳过的采样点", "State": "状态", "Storyline and signal strength before drilling into positions and actions.": "先看叙事与信号强度,再深入持仓与操作。", "Streaming": "采样中", "Suggested next steps": "建议的下一步", "The line of investigation and where the open questions concentrate.": "研究主线,以及待答问题的集中之处。", "The narrative arc and how strongly themes are trending.": "叙事走向,以及主题的趋势强度。", "The world model asked for these capabilities and nothing took them up.": "世界模型请求了这些能力,但无人受理。", "Theme intensity": "主题强度", "Themes": "主题", "This board reports how the framework changes itself. Nothing has been recorded yet.": "本看板报告框架如何改变自身。目前尚无任何记录。", "To": "到", "Tool": "工具", "Tool-name conflicts": "工具名冲突", "Tools": "工具数", "Transport": "传输方式", "Transport, provenance and channel counts": "传输方式、来源与通道数量", "Trust": "信任级别", "Trust accrual": "信任累积", "Trust class": "信任语义", "Unselectable reclamation": "不可选回收", "VERIFIED": "已验证级", "Verified": "已核验", "Verified by": "验证依据", "Voices & concerns": "声音与关切", "Watchlist": "关注列表", "What changed in the environment, and what the framework did about it.": "环境发生了什么变化,框架又为此做了什么。", "Which plugin owns which tool, and which capability that tool provides.": "哪个插件拥有哪个工具,以及该工具提供什么能力。", "Who/what is in the conversation, and the concerns still open.": "谁/什么在被讨论,以及尚未解决的关切。", "Why": "原因", "Why not admitted": "未准入原因", "Why this page is empty": "这个页面为何是空的", "World-model driver": "世界模型驱动器", "Writable": "可写", "aborted": "已中断", "accruing": "正在累积", "active": "运行中", "appeared": "新出现", "armed": "已就绪", "assess_compatibility": "评估兼容性", "built_in": "内置", "capability_expand": "扩展能力", "committed": "已定论", "conformance": "合规", "declared_fitness": "声明式适配", "disable": "停用", "disposed": "已释放", "environment_probe": "环境探测", "failed": "已失败", "frozen": "已冻结", "gone": "已消失", "idle": "空闲无变化", "install": "安装", "loading": "加载中", "manual": "人工", "moved": "已迁移", "new_unproven": "新,未验证", "no": "否", "no_evidence": "无证据", "none": "无", "not_admitted": "未准入", "not_applicable": "不适用", "observe_only": "仅观察", "observed_effect": "观测效果", "open": "进行中", "pending": "待启", "reload": "重载", "remove": "移除", "reopened": "已复发", "resolved": "已闭合", "rollback": "回滚", "runtime": "运行时", "self_acquired": "自获取", "still_open": "仍未闭合", "trusted": "已信任", "unknown": "未知", "unknown_tool": "未知工具", "unloading": "卸载中", "unscheduled": "未调度", "unverifiable": "无法核实", "unverified": "未验证", "waiting": "等待首个周期", "watching": "监视中", "wired": "已贯通", "world_model": "世界模型", "yes": "是"}, - fr: {"> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **Régression : un écart comblé s'est reproduit.** Une évolution qui semblait réussie n'a pas tenu. C'est le seul constat de ce tableau qui exige une attention immédiate.", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **Instantané seulement.** Aucun historique causal à reconstruire pour l'instant : la chronologie est absente, non vide. La raison est indiquée par la ligne `Décisions de politique` sous la couverture du pipeline ; l'instantané et le tableau de couverture ne sont pas affectés.", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **Certains plugins sont gelés par un défaut interne.** Un plugin gelé signale toujours `DRAFT`, et la dimension de confiance ne fait que *noter*, donc il reste sélectionnable tant qu'il n'est pas également désenregistré — voir la colonne `Sélectionnable`.", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **Niveau de vérification : L2 (aptitude déclarée).** Une observation retirée signifie qu'un candidat a *déclaré* fournir la capacité, non que la capacité a été observée en fonctionnement. La vérification d'effet (L3) n'est pas câblée, donc aucune clôture de ce tableau ne doit être lue comme prouvée.", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> Un cycle d'observation doit s'achever avant qu'il y ait quoi que ce soit à montrer. Si cela persiste, vérifiez que le planificateur est actif et que la surveillance `framework-evolution` est armée et non silencée.", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> Un épisode est écrit lorsqu'une observation de l'environnement conduit à une décision de capacité. Aucun n'a été enregistré : soit le système est calme, soit le pipeline s'arrête plus tôt — l'onglet **Pipeline** nomme le segment où il s'arrête et ce qui le débloquerait.", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> Rien ne les récupère automatiquement. Chacun occupe un nom d'outil et figure dans la liste des capacités sans être sélectionnable : le registre grandit dans une direction qu'aucune exigence ne peut utiliser.", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> Ces propositions n'ont intégré aucun pipeline : elles n'apparaissent donc dans aucun enregistrement de décision ni observation. Les admettre est un choix de configuration.", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "Un ratio inférieur à 1,0 signifie que la boucle d’échantillonnage ne tient pas sa cadence déclarée.", "Acquisition authority": "Autorité d'acquisition", "Acquisition lifecycle": "Cycle de vie d'acquisition", "Action": "Action", "After": "Après", "An unverified declaration has its writable channels demoted to read-only.": "Une déclaration non vérifiée voit ses canaux inscriptibles rétrogradés en lecture seule.", "Approval": "Approbation", "Autonomous governance": "Gouvernance autonome", "Autonomy": "Autonomie", "Before": "Avant", "CANDIDATE": "Candidat", "Calibrated at": "Calibré le", "Calibration health": "État de calibration", "Calls": "Appels", "Calls (decisions)": "Recommandations (décisions)", "Candlestick": "Chandeliers", "Capability": "Capacité", "Capability adaptation": "Adaptation des capacités", "Capability observations": "Observations de capacités", "Capability ownership": "Propriété des capacités", "Capability topology": "Topologie des capacités", "Change": "Changement", "Channel": "Canal", "Channels": "Canaux", "Channels that have never been calibrated or whose calibration has expired are shown first.": "Les canaux jamais calibrés ou dont la calibration a expiré apparaissent en premier.", "Command": "Commande", "Commanded versus observed, best tracking first": "Commandé contre observé, meilleur suivi d’abord", "Composition": "Composition", "Concerns (open questions)": "Préoccupations (questions ouvertes)", "Confidence": "Confiance", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "Compté sur tous les canaux tracés. « près » signifie à moins de 5 % d’une borne déclarée.", "Cycles run": "Cycles exécutés", "DRAFT": "Brouillon", "Days since": "Jours écoulés", "Decision": "Décision", "Decisions read as calls; action items as the execution checklist.": "Les décisions se lisent comme des recommandations ; les actions comme la liste d’exécution.", "Declared Hz": "Hz déclarés", "Desk brief": "Note de desk", "Device": "Appareil", "Dropped samples": "Échantillons perdus", "Each row names one blocked segment and the change that would unblock it.": "Chaque ligne nomme un segment bloqué et le changement qui le débloquerait.", "Effect verification (L3)": "Vérification d'effet (L3)", "Entities as references, and recommended next prompts to advance the work.": "Entités comme références, et invites suivantes recommandées pour avancer.", "Entities in play and the open risks still to resolve.": "Entités concernées et risques ouverts à résoudre.", "Envelope, rate, staleness and quality observations · newest first": "Observations d’enveloppe, de débit, d’obsolescence et de qualité · les plus récentes d’abord", "Environment": "Environnement", "Environment to framework": "De l'environnement au framework", "Environment, selected plugin tools, and orchestration order.": "Environnement, outils de plugin sélectionnés et ordre d’orchestration.", "Error rate": "Taux d'erreur", "Events paced out": "Événements limités", "Ever used": "Déjà utilisé", "Evidence": "Preuve", "Evidence admission": "Admission des preuves", "Evolution": "Évolution", "Evolution timeline": "Chronologie de l'évolution", "Executable": "Exécutable", "Execution checklist": "Liste d’exécution", "Extracted from this session's tool/file output (not model-generated).": "Extrait des sorties d’outils/fichiers de cette session (non généré par le modèle).", "Failures": "Échecs", "Fiber": "Fibre", "Fiber state changes since the previous cycle, including load retries.": "Changements d'état de fiber depuis le cycle précédent, y compris les tentatives de chargement.", "Finance lens": "Vue finance", "Follow-ups": "Suivis", "Framework change": "Changement du framework", "Framework changes as they happened, from runtime probes.": "Changements du framework en temps réel, via les sondes d'exécution.", "Framework evolution": "Évolution du framework", "Framework size and how much of the evolution pipeline shows runtime evidence.": "Taille du framework et part du pipeline d'évolution qui présente des preuves d'exécution.", "From": "De", "Frozen plugins": "Plugins gelés", "Gap closure": "Clôture de l'écart", "Halt": "Arrêt", "How closures are verified": "Comment les clôtures sont vérifiées", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "Quelle part du framework il a fait croître lui-même, et quelle part du pipeline présente des preuves d'exécution.", "How often each window sat inside, near, or outside its declared limits": "Fréquence à laquelle chaque fenêtre était dans, près de, ou hors de ses limites déclarées", "Inquiry brief": "Note d’enquête", "Insights carded as evidence, capped for fast review.": "Analyses présentées comme preuves, limitées pour une revue rapide.", "Instruments & counterparties": "Instruments et contreparties", "Kept": "Conservé", "Latest capability decision": "Dernière décision de capacité", "Lifecycle records": "Enregistrements de cycle de vie", "Lifecycle timeline": "Chronologie du cycle de vie", "Lifecycle transitions": "Transitions de cycle de vie", "Line of inquiry": "Ligne d’enquête", "Live activity": "Activité en direct", "Location": "Emplacement", "Loop phase": "Phase de boucle", "Mean of each downsample window. Declared limits are listed per channel below.": "Moyenne de chaque fenêtre de sous-échantillonnage. Les limites déclarées figurent par canal ci-dessous.", "Model's reasoning": "Raisonnement du modèle", "Mutation": "Mutation", "Narrative": "Récit", "Narrative pulse": "Pouls narratif", "Needs attention": "Requiert attention", "Next recal due": "Prochaine recalibration", "Next step": "Étape suivante", "No causal history yet": "Pas encore d'historique causal", "Normalized error": "Erreur normalisée", "Normalized error is the residual as a share of the channel's declared span.": "L’erreur normalisée est le résidu en proportion de l’étendue déclarée du canal.", "Not yet observed": "Pas encore observé", "Nothing has driven a framework change, so there is no episode to narrate.": "Rien n'a encore déclenché de changement du framework : il n'y a donc aucun épisode à raconter.", "OHLC extracted from captured session market data.": "OHLC extrait des données de marché capturées durant la session.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "File d’observations, état des propositions, décisions de politique et résultats du cycle de vie.", "Observations": "Observations", "Observed Hz": "Hz observés", "Observed rate against declared rate": "Débit observé par rapport au débit déclaré", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "Un espace de noms global unique, arbitré au premier arrivé. Le concurrent est enregistré, jamais supprimé en silence.", "Open": "Ouvert", "Open risks": "Risques ouverts", "Open/high/low/close from captured tool output.": "Ouverture/haut/bas/clôture issus des sorties d’outils capturées.", "Origin": "Origine", "Outcome": "Résultat", "PRODUCTION": "Production", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "Par épisode : le déclencheur, la décision, le changement, et si l'écart a été comblé.", "Per-channel calibration state, freshness, and residual correction": "État de calibration, fraîcheur et correction résiduelle par canal", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "Preuves d'exécution par segment. L'existence d'un module ne prouve pas qu'il soit appelé.", "Pipeline": "Pipeline", "Pipeline evidence": "Preuves du pipeline", "Pipeline reachability": "Accessibilité du pipeline", "Plan": "Plan", "Plan steps": "Étapes du plan", "Plugin": "Plugin", "Plugin roster and trust": "Registre des plugins et confiance", "Plugins": "Plugins", "Plugins by origin": "Plugins par origine", "Plugins by trust class": "Plugins par classe de confiance", "Policy": "Politique", "Policy decisions": "Décisions de politique", "Positions & actions": "Positions et actions", "Posture": "Posture", "Price action": "Action des prix", "Proposal": "Proposition", "Proposal status": "Statut de la proposition", "Proposed, not admitted": "Proposé, non admis", "Pulse": "Pouls", "Quarantine feed": "Flux de quarantaine", "Ratio": "Ratio", "Read live from the registry and trust ledger every cycle.": "Lu en direct depuis le registre et le registre de confiance à chaque cycle.", "Recent episodes": "Épisodes récents", "Reclaim candidates": "Candidats à la récupération", "Reclaimable": "Récupérable", "References & follow-ups": "Références et suivis", "References (entities)": "Références (entités)", "Registry": "Registre", "Registry delta": "Delta du registre", "Registry version": "Version du registre", "Regressions": "Régressions", "Rejected": "Rejeté", "Representative observations, capped for quick scanning.": "Observations représentatives, limitées pour une lecture rapide.", "Requirements": "Exigences", "Research lens": "Vue recherche", "Residual": "Résidu", "Runtime evidence": "Preuve d'exécution", "Sampled history per channel, newest on the right": "Historique échantillonné par canal, le plus récent à droite", "Segment": "Segment", "Segments by status": "Segments par statut", "Selectable": "Sélectionnable", "Selection delta": "Delta de sélection", "Self-acquired": "Auto-acquis", "Self-acquired plugins that are registered but unselectable or never once used.": "Plugins auto-acquis qui sont enregistrés mais non sélectionnables, ou jamais utilisés une seule fois.", "Sentiment lens": "Vue sentiment", "Series": "Série", "Session analysis": "Analyse de session", "Signal strength": "Force du signal", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "Signaux indiquant qu'une évolution a mal tourné ou a été retenue. Affichés quel que soit l'onglet ouvert.", "Skipped slots": "Créneaux manqués", "State": "État", "Storyline and signal strength before drilling into positions and actions.": "Récit et force du signal avant d’examiner positions et actions.", "Streaming": "Diffusion", "Suggested next steps": "Prochaines étapes suggérées", "The line of investigation and where the open questions concentrate.": "La ligne d’investigation et où se concentrent les questions ouvertes.", "The narrative arc and how strongly themes are trending.": "L’arc narratif et l’intensité des tendances thématiques.", "The world model asked for these capabilities and nothing took them up.": "Le modèle du monde a demandé ces capacités et personne ne les a prises en charge.", "Theme intensity": "Intensité des thèmes", "Themes": "Thèmes", "This board reports how the framework changes itself. Nothing has been recorded yet.": "Ce tableau rend compte de la façon dont le framework se modifie lui-même. Rien n'a encore été enregistré.", "To": "Vers", "Tool": "Outil", "Tool-name conflicts": "Conflits de noms d'outils", "Tools": "Outils", "Transport": "Transport", "Transport, provenance and channel counts": "Transport, provenance et nombre de canaux", "Trust": "Confiance", "Trust accrual": "Accumulation de confiance", "Trust class": "Classe de confiance", "Unselectable reclamation": "Récupération non sélectionnable", "VERIFIED": "Vérifié", "Verified": "Vérifié", "Verified by": "Vérifié par", "Voices & concerns": "Voix et préoccupations", "Watchlist": "Liste de suivi", "What changed in the environment, and what the framework did about it.": "Ce qui a changé dans l'environnement, et ce que le framework a fait en réponse.", "Which plugin owns which tool, and which capability that tool provides.": "Quel plugin possède quel outil, et quelle capacité cet outil fournit.", "Who/what is in the conversation, and the concerns still open.": "Qui/quoi est dans la conversation, et les préoccupations encore ouvertes.", "Why": "Pourquoi", "Why not admitted": "Motif de non-admission", "Why this page is empty": "Pourquoi cette page est vide", "World-model driver": "Pilote du modèle du monde", "Writable": "Inscriptible", "aborted": "Abandonné", "accruing": "En accumulation", "active": "Actif", "appeared": "Apparu", "armed": "Armé", "assess_compatibility": "Évaluer la compatibilité", "built_in": "Intégré", "capability_expand": "Étendre les capacités", "committed": "Conclu", "conformance": "Conformité", "declared_fitness": "Aptitude déclarée", "disable": "Désactiver", "disposed": "Libéré", "environment_probe": "Sonde d'environnement", "failed": "Échoué", "frozen": "Gelé", "gone": "Disparu", "idle": "Au repos", "install": "Installer", "loading": "Chargement", "manual": "Manuel", "moved": "Déplacé", "new_unproven": "Nouveau, non éprouvé", "no": "Non", "no_evidence": "Aucune preuve", "none": "Aucun", "not_admitted": "Non admis", "not_applicable": "Sans objet", "observe_only": "Observer seulement", "observed_effect": "Effet observé", "open": "Ouvert", "pending": "En attente", "reload": "Recharger", "remove": "Supprimer", "reopened": "Réouvert", "resolved": "Résolu", "rollback": "Annuler", "runtime": "Exécution", "self_acquired": "Auto-acquis", "still_open": "Toujours ouvert", "trusted": "De confiance", "unknown": "Inconnu", "unknown_tool": "Outil inconnu", "unloading": "Déchargement", "unscheduled": "Non planifié", "unverifiable": "Invérifiable", "unverified": "Non vérifié", "waiting": "En attente", "watching": "En surveillance", "wired": "Câblé", "world_model": "Modèle du monde", "yes": "Oui"}, - es: {"> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **Regresión: una brecha cerrada ha vuelto a aparecer.** Una evolución que parecía exitosa no se sostuvo. Es el único hallazgo de este panel que exige atención inmediata.", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **Solo instantánea.** Todavía no hay historia causal que reconstruir, por lo que la cronología está ausente, no vacía. El motivo lo indica la fila `Decisiones de política` bajo la cobertura del pipeline; la instantánea y la tabla de cobertura no se ven afectadas.", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **Algunos plugins están congelados por un defecto interno.** Un plugin congelado sigue informando `DRAFT`, y la dimensión de confianza solo *puntúa*, por lo que permanece seleccionable a menos que también se desregistre — consulte la columna `Seleccionable`.", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **Nivel de verificación: L2 (aptitud declarada).** Una observación retirada significa que un candidato *declaró* que proporciona la capacidad, no que se observara funcionando. La verificación de efecto (L3) no está conectada, así que ningún cierre de este panel debe leerse como probado.", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> Debe completarse un ciclo de observación antes de que haya algo que mostrar. Si persiste, compruebe que el planificador está activo y que la vigilancia `framework-evolution` está armada y no silenciada.", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> Un episodio se escribe cuando una observación del entorno conduce a una decisión de capacidad. No se ha registrado ninguno: o el sistema está tranquilo o el pipeline se detiene antes — la pestaña **Pipeline** nombra el segmento donde se detiene y qué lo desbloquearía.", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> Nada los recupera automáticamente. Cada uno ocupa un nombre de herramienta y aparece en la lista de capacidades sin ser seleccionable: el registro crece en una dirección que ningún requisito puede usar.", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> Estas propuestas no entraron en ningún pipeline, por lo que no aparecen en ningún registro de decisión ni observación. Admitirlas es una elección de configuración.", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "Una relación inferior a 1,0 significa que el bucle de muestreo no mantiene su cadencia declarada.", "Acquisition authority": "Autoridad de adquisición", "Acquisition lifecycle": "Ciclo de vida de adquisición", "Action": "Acción", "After": "Después", "An unverified declaration has its writable channels demoted to read-only.": "Una declaración no verificada degrada sus canales escribibles a solo lectura.", "Approval": "Aprobación", "Autonomous governance": "Gobernanza autónoma", "Autonomy": "Autonomía", "Before": "Antes", "CANDIDATE": "Candidato", "Calibrated at": "Calibrado el", "Calibration health": "Estado de calibración", "Calls": "Llamadas", "Calls (decisions)": "Recomendaciones (decisiones)", "Candlestick": "Velas", "Capability": "Capacidad", "Capability adaptation": "Adaptación de capacidades", "Capability observations": "Observaciones de capacidad", "Capability ownership": "Propiedad de capacidades", "Capability topology": "Topología de capacidades", "Change": "Cambio", "Channel": "Canal", "Channels": "Canales", "Channels that have never been calibrated or whose calibration has expired are shown first.": "Los canales nunca calibrados o con calibración vencida se muestran primero.", "Command": "Comando", "Commanded versus observed, best tracking first": "Comandado frente a observado, mejor seguimiento primero", "Composition": "Composición", "Concerns (open questions)": "Inquietudes (preguntas abiertas)", "Confidence": "Confianza", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "Contado en todos los canales graficados. «cerca» significa dentro del 5 % de un límite declarado.", "Cycles run": "Ciclos ejecutados", "DRAFT": "Borrador", "Days since": "Días desde", "Decision": "Decisión", "Decisions read as calls; action items as the execution checklist.": "Las decisiones se leen como recomendaciones; las acciones como la lista de ejecución.", "Declared Hz": "Hz declarados", "Desk brief": "Informe de mesa", "Device": "Dispositivo", "Dropped samples": "Muestras descartadas", "Each row names one blocked segment and the change that would unblock it.": "Cada fila nombra un segmento bloqueado y el cambio que lo desbloquearía.", "Effect verification (L3)": "Verificación de efecto (L3)", "Entities as references, and recommended next prompts to advance the work.": "Entidades como referencias y siguientes preguntas recomendadas para avanzar.", "Entities in play and the open risks still to resolve.": "Entidades implicadas y riesgos abiertos por resolver.", "Envelope, rate, staleness and quality observations · newest first": "Observaciones de envolvente, tasa, obsolescencia y calidad · las más recientes primero", "Environment": "Entorno", "Environment to framework": "Del entorno al framework", "Environment, selected plugin tools, and orchestration order.": "Entorno, herramientas de plugin seleccionadas y orden de orquestación.", "Error rate": "Tasa de error", "Events paced out": "Eventos limitados", "Ever used": "Alguna vez usado", "Evidence": "Evidencia", "Evidence admission": "Admisión de evidencia", "Evolution": "Evolución", "Evolution timeline": "Cronología de la evolución", "Executable": "Ejecutable", "Execution checklist": "Lista de ejecución", "Extracted from this session's tool/file output (not model-generated).": "Extraído de la salida de herramientas/archivos de esta sesión (no generado por el modelo).", "Failures": "Fallos", "Fiber": "Fibra", "Fiber state changes since the previous cycle, including load retries.": "Cambios de estado de fiber desde el ciclo anterior, incluidos los reintentos de carga.", "Finance lens": "Vista financiera", "Follow-ups": "Seguimientos", "Framework change": "Cambio del framework", "Framework changes as they happened, from runtime probes.": "Cambios del framework en tiempo real, desde sondas de ejecución.", "Framework evolution": "Evolución del framework", "Framework size and how much of the evolution pipeline shows runtime evidence.": "Tamaño del framework y qué parte del pipeline de evolución muestra evidencia en ejecución.", "From": "Desde", "Frozen plugins": "Plugins congelados", "Gap closure": "Cierre de la brecha", "Halt": "Parada", "How closures are verified": "Cómo se verifican los cierres", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "Cuánto del framework hizo crecer por sí mismo y cuánto del pipeline muestra evidencia de ejecución.", "How often each window sat inside, near, or outside its declared limits": "Con qué frecuencia cada ventana estuvo dentro, cerca o fuera de sus límites declarados", "Inquiry brief": "Informe de indagación", "Insights carded as evidence, capped for fast review.": "Hallazgos presentados como evidencia, limitados para revisión rápida.", "Instruments & counterparties": "Instrumentos y contrapartes", "Kept": "Conservado", "Latest capability decision": "Última decisión de capacidad", "Lifecycle records": "Registros de ciclo de vida", "Lifecycle timeline": "Cronología del ciclo de vida", "Lifecycle transitions": "Transiciones de ciclo de vida", "Line of inquiry": "Línea de indagación", "Live activity": "Actividad en vivo", "Location": "Ubicación", "Loop phase": "Fase del bucle", "Mean of each downsample window. Declared limits are listed per channel below.": "Media de cada ventana de submuestreo. Los límites declarados se listan por canal abajo.", "Model's reasoning": "Razonamiento del modelo", "Mutation": "Mutación", "Narrative": "Narrativa", "Narrative pulse": "Pulso narrativo", "Needs attention": "Requiere atención", "Next recal due": "Próxima recalibración", "Next step": "Siguiente paso", "No causal history yet": "Aún no hay historia causal", "Normalized error": "Error normalizado", "Normalized error is the residual as a share of the channel's declared span.": "El error normalizado es el residuo como fracción del rango declarado del canal.", "Not yet observed": "Aún no observado", "Nothing has driven a framework change, so there is no episode to narrate.": "Nada ha impulsado todavía un cambio del framework, por lo que no hay ningún episodio que narrar.", "OHLC extracted from captured session market data.": "OHLC extraído de los datos de mercado capturados en la sesión.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "Cola de observaciones, estado de propuestas, decisiones de política y resultados del ciclo de vida.", "Observations": "Observaciones", "Observed Hz": "Hz observados", "Observed rate against declared rate": "Tasa observada frente a la tasa declarada", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "Un único espacio de nombres global, arbitrado por orden de llegada. El aspirante queda registrado, nunca se descarta en silencio.", "Open": "Abierto", "Open risks": "Riesgos abiertos", "Open/high/low/close from captured tool output.": "Apertura/máximo/mínimo/cierre desde la salida de herramientas capturada.", "Origin": "Origen", "Outcome": "Resultado", "PRODUCTION": "Producción", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "Por episodio: el desencadenante, la decisión, el cambio y si la brecha se cerró.", "Per-channel calibration state, freshness, and residual correction": "Estado de calibración, vigencia y corrección residual por canal", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "Evidencia en ejecución por segmento. Que un módulo exista no prueba que algo lo invoque.", "Pipeline": "Pipeline", "Pipeline evidence": "Evidencia del pipeline", "Pipeline reachability": "Alcanzabilidad del pipeline", "Plan": "Plan", "Plan steps": "Pasos del plan", "Plugin": "Plugin", "Plugin roster and trust": "Registro de plugins y confianza", "Plugins": "Plugins", "Plugins by origin": "Plugins por origen", "Plugins by trust class": "Plugins por clase de confianza", "Policy": "Política", "Policy decisions": "Decisiones de política", "Positions & actions": "Posiciones y acciones", "Posture": "Postura", "Price action": "Acción del precio", "Proposal": "Propuesta", "Proposal status": "Estado de la propuesta", "Proposed, not admitted": "Propuesto, no admitido", "Pulse": "Pulso", "Quarantine feed": "Entrada de cuarentena", "Ratio": "Relación", "Read live from the registry and trust ledger every cycle.": "Leído en vivo del registro y del libro de confianza en cada ciclo.", "Recent episodes": "Episodios recientes", "Reclaim candidates": "Candidatos a recuperación", "Reclaimable": "Recuperable", "References & follow-ups": "Referencias y seguimientos", "References (entities)": "Referencias (entidades)", "Registry": "Registro", "Registry delta": "Delta del registro", "Registry version": "Versión del registro", "Regressions": "Regresiones", "Rejected": "Rechazado", "Representative observations, capped for quick scanning.": "Observaciones representativas, limitadas para lectura rápida.", "Requirements": "Requisitos", "Research lens": "Vista de investigación", "Residual": "Residuo", "Runtime evidence": "Evidencia en ejecución", "Sampled history per channel, newest on the right": "Historial muestreado por canal, el más reciente a la derecha", "Segment": "Segmento", "Segments by status": "Segmentos por estado", "Selectable": "Seleccionable", "Selection delta": "Delta de selección", "Self-acquired": "Autoadquirido", "Self-acquired plugins that are registered but unselectable or never once used.": "Plugins autoadquiridos que están registrados pero no son seleccionables, o nunca se han usado.", "Sentiment lens": "Vista de sentimiento", "Series": "Serie", "Session analysis": "Análisis de sesión", "Signal strength": "Fuerza de la señal", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "Señales de que algo creció mal o fue retenido. Se muestran independientemente de la pestaña abierta.", "Skipped slots": "Ranuras omitidas", "State": "Estado", "Storyline and signal strength before drilling into positions and actions.": "Narrativa y fuerza de la señal antes de entrar en posiciones y acciones.", "Streaming": "Transmisión", "Suggested next steps": "Próximos pasos sugeridos", "The line of investigation and where the open questions concentrate.": "La línea de investigación y dónde se concentran las preguntas abiertas.", "The narrative arc and how strongly themes are trending.": "El arco narrativo y con qué fuerza se mueven los temas.", "The world model asked for these capabilities and nothing took them up.": "El modelo del mundo pidió estas capacidades y nada las asumió.", "Theme intensity": "Intensidad temática", "Themes": "Temas", "This board reports how the framework changes itself. Nothing has been recorded yet.": "Este panel informa de cómo el framework se modifica a sí mismo. Todavía no se ha registrado nada.", "To": "Hasta", "Tool": "Herramienta", "Tool-name conflicts": "Conflictos de nombres de herramientas", "Tools": "Herramientas", "Transport": "Transporte", "Transport, provenance and channel counts": "Transporte, procedencia y número de canales", "Trust": "Confianza", "Trust accrual": "Acumulación de confianza", "Trust class": "Clase de confianza", "Unselectable reclamation": "Recuperación no seleccionable", "VERIFIED": "Verificado", "Verified": "Verificado", "Verified by": "Verificado por", "Voices & concerns": "Voces e inquietudes", "Watchlist": "Lista de seguimiento", "What changed in the environment, and what the framework did about it.": "Qué cambió en el entorno y qué hizo el framework al respecto.", "Which plugin owns which tool, and which capability that tool provides.": "Qué plugin posee qué herramienta y qué capacidad proporciona esa herramienta.", "Who/what is in the conversation, and the concerns still open.": "Quién/qué está en la conversación y las inquietudes aún abiertas.", "Why": "Por qué", "Why not admitted": "Motivo de no admisión", "Why this page is empty": "Por qué esta página está vacía", "World-model driver": "Controlador del modelo del mundo", "Writable": "Escribible", "aborted": "Abortado", "accruing": "Acumulando", "active": "Activo", "appeared": "Apareció", "armed": "Armado", "assess_compatibility": "Evaluar compatibilidad", "built_in": "Integrado", "capability_expand": "Ampliar capacidad", "committed": "Concluido", "conformance": "Conformidad", "declared_fitness": "Aptitud declarada", "disable": "Desactivar", "disposed": "Liberado", "environment_probe": "Sonda de entorno", "failed": "Fallido", "frozen": "Congelado", "gone": "Desapareció", "idle": "Inactivo", "install": "Instalar", "loading": "Cargando", "manual": "Manual", "moved": "Se movió", "new_unproven": "Nuevo, no probado", "no": "No", "no_evidence": "Sin evidencia", "none": "Ninguno", "not_admitted": "No admitido", "not_applicable": "No aplicable", "observe_only": "Solo observar", "observed_effect": "Efecto observado", "open": "Abierto", "pending": "Pendiente", "reload": "Recargar", "remove": "Eliminar", "reopened": "Reabierto", "resolved": "Resuelto", "rollback": "Revertir", "runtime": "Tiempo de ejecución", "self_acquired": "Autoadquirido", "still_open": "Aún abierto", "trusted": "De confianza", "unknown": "Desconocido", "unknown_tool": "Herramienta desconocida", "unloading": "Descargando", "unscheduled": "No planificado", "unverifiable": "No verificable", "unverified": "No verificado", "waiting": "En espera", "watching": "Vigilando", "wired": "Conectado", "world_model": "Modelo del mundo", "yes": "Sí"}, - ar: {"> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **انحدار: فجوة أُغلقت عادت للظهور.** تطوّر بدا ناجحًا لم يصمد. هذا هو الاكتشاف الوحيد في هذه اللوحة الذي يستدعي انتباهًا فوريًا.", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **لقطة فقط.** لا يوجد بعد تاريخ سببي لإعادة بنائه، لذا فالخط الزمني غائب وليس فارغًا. السبب مبيَّن في صف `قرارات السياسة` تحت تغطية المسار؛ اللقطة الحيّة وجدول التغطية غير متأثرين.", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **بعض الإضافات مُجمَّدة بسبب خلل داخلي.** الإضافة المُجمَّدة لا تزال تُبلِّغ `DRAFT`، وبُعد الثقة يقوم بالتقييم فقط، لذا تبقى قابلة للاختيار إلا إذا أُلغي تسجيلها أيضًا — راجع عمود `قابل للاختيار`.", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **مستوى التحقق: L2 (الملاءمة المُعلنة).** سحب الرصد يعني أن مرشّحًا *أعلن* أنه يوفّر القدرة، لا أن القدرة رُصدت وهي تعمل. التحقق من الأثر (L3) غير موصول، لذا لا ينبغي قراءة أي إغلاق في هذه اللوحة كأمر مُثبَت.", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> يجب أن تكتمل دورة مراقبة واحدة قبل ظهور أي محتوى. إذا استمر ذلك، تحقّق من تمكين المُجدول وأن مراقبة `framework-evolution` مُسلّحة وغير مكتومة.", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> تُكتب الحلقة عندما يؤدي رصد للبيئة إلى قرار بشأن قدرة. لم يُسجَّل أي منها، وهذا يعني إمّا نظامًا هادئًا أو مسارًا يتوقف قبل ذلك — تبويب **المسار** يحدّد الجزء الذي يتوقف عنده وما الذي يزيل التعطيل.", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> لا شيء يستعيدها تلقائيًا. كل واحدة تحتجز اسم أداة وتظهر في قائمة القدرات دون أن تكون قابلة للاختيار، فينمو السجل في اتجاه لا يمكن لأي مطلب استخدامه.", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> لم تدخل هذه المقترحات أي مسار، لذا لا تظهر في أي سجل قرار أو رصد. قبولها خيار في الإعدادات.", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "نسبة أقل من 1.0 تعني أن حلقة أخذ العينات لا تحافظ على وتيرتها المعلنة.", "Acquisition authority": "سلطة الاكتساب", "Acquisition lifecycle": "دورة حياة الاكتساب", "Action": "الإجراء", "After": "بعد", "An unverified declaration has its writable channels demoted to read-only.": "الإعلان غير المُتحقَّق منه تُخفَّض قنواته القابلة للكتابة إلى القراءة فقط.", "Approval": "الموافقة", "Autonomous governance": "الحكم الذاتي", "Autonomy": "الاستقلالية", "Before": "قبل", "CANDIDATE": "مرشّح", "Calibrated at": "تاريخ المعايرة", "Calibration health": "سلامة المعايرة", "Calls": "الاستدعاءات", "Calls (decisions)": "التوصيات (القرارات)", "Candlestick": "الشموع", "Capability": "القدرة", "Capability adaptation": "تكييف القدرات", "Capability observations": "رصد القدرات", "Capability ownership": "ملكية القدرات", "Capability topology": "طوبولوجيا القدرات", "Change": "التغيير", "Channel": "القناة", "Channels": "القنوات", "Channels that have never been calibrated or whose calibration has expired are shown first.": "تظهر أولاً القنوات التي لم تُعاير قط أو التي انتهت صلاحية معايرتها.", "Command": "الأمر", "Commanded versus observed, best tracking first": "المأمور مقابل المرصود، الأفضل تتبعاً أولاً", "Composition": "التركيب", "Concerns (open questions)": "المخاوف (أسئلة مفتوحة)", "Confidence": "الثقة", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "محسوب على كل قناة مرسومة. \"قريب\" تعني داخل 5% من حد معلن.", "Cycles run": "الدورات المنفَّذة", "DRAFT": "مسوّدة", "Days since": "الأيام المنقضية", "Decision": "القرار", "Decisions read as calls; action items as the execution checklist.": "القرارات تُقرأ كتوصيات؛ والإجراءات كقائمة تنفيذ.", "Declared Hz": "الهرتز المعلن", "Desk brief": "موجز المكتب", "Device": "الجهاز", "Dropped samples": "العينات المفقودة", "Each row names one blocked segment and the change that would unblock it.": "كل صف يحدّد جزءًا معطَّلًا والتغيير الذي يزيل التعطيل.", "Effect verification (L3)": "التحقق من الأثر (L3)", "Entities as references, and recommended next prompts to advance the work.": "الكيانات كمراجع، والمطالبات التالية الموصى بها لدفع العمل.", "Entities in play and the open risks still to resolve.": "الكيانات المعنية والمخاطر المفتوحة التي لم تُحل.", "Envelope, rate, staleness and quality observations · newest first": "رصدات المغلف والمعدل والتقادم والجودة · الأحدث أولاً", "Environment": "البيئة", "Environment to framework": "من البيئة إلى الإطار", "Environment, selected plugin tools, and orchestration order.": "البيئة والأدوات المختارة وترتيب التنسيق.", "Error rate": "معدل الأخطاء", "Events paced out": "الأحداث المُقيَّدة", "Ever used": "استُخدم سابقًا", "Evidence": "الدليل", "Evidence admission": "قبول الأدلة", "Evolution": "التطور", "Evolution timeline": "الخط الزمني للتطور", "Executable": "قابل للتنفيذ", "Execution checklist": "قائمة التنفيذ", "Extracted from this session's tool/file output (not model-generated).": "مستخرج من مخرجات الأدوات/الملفات في هذه الجلسة (ليس من إنشاء النموذج).", "Failures": "الأعطال", "Fiber": "الخيط", "Fiber state changes since the previous cycle, including load retries.": "تغييرات حالة الـ fiber منذ الدورة السابقة، بما في ذلك محاولات التحميل.", "Finance lens": "منظور مالي", "Follow-ups": "المتابعات", "Framework change": "تغيير الإطار", "Framework changes as they happened, from runtime probes.": "تغييرات الإطار لحظة حدوثها، من مجسّات وقت التشغيل.", "Framework evolution": "تطور الإطار", "Framework size and how much of the evolution pipeline shows runtime evidence.": "حجم الإطار ومقدار ما يُظهره مسار التطور من أدلة وقت التشغيل.", "From": "من", "Frozen plugins": "الإضافات المُجمَّدة", "Gap closure": "إغلاق الفجوة", "Halt": "إيقاف", "How closures are verified": "كيف يُتحقَّق من الإغلاقات", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "ما مقدار ما نمّاه الإطار بنفسه، وما مقدار المسار الذي يُظهر أدلة وقت التشغيل.", "How often each window sat inside, near, or outside its declared limits": "عدد المرات التي كانت فيها كل نافذة داخل حدودها المعلنة أو قريبة منها أو خارجها", "Inquiry brief": "موجز الاستقصاء", "Insights carded as evidence, capped for fast review.": "الرؤى معروضة كأدلة، ومحدودة العدد للمراجعة السريعة.", "Instruments & counterparties": "الأدوات والأطراف المقابلة", "Kept": "المحتفظ به", "Latest capability decision": "أحدث قرار للقدرات", "Lifecycle records": "سجلات دورة الحياة", "Lifecycle timeline": "الخط الزمني لدورة الحياة", "Lifecycle transitions": "انتقالات دورة الحياة", "Line of inquiry": "خط الاستقصاء", "Live activity": "النشاط المباشر", "Location": "الموقع", "Loop phase": "مرحلة الحلقة", "Mean of each downsample window. Declared limits are listed per channel below.": "متوسط كل نافذة تخفيض للعينات. الحدود المعلنة مدرجة لكل قناة أدناه.", "Model's reasoning": "استدلال النموذج", "Mutation": "التغيير", "Narrative": "السرد", "Narrative pulse": "نبض السرد", "Needs attention": "يستدعي الانتباه", "Next recal due": "موعد إعادة المعايرة", "Next step": "الخطوة التالية", "No causal history yet": "لا يوجد تاريخ سببي بعد", "Normalized error": "الخطأ المعياري", "Normalized error is the residual as a share of the channel's declared span.": "الخطأ المعياري هو المتبقي كنسبة من المدى المعلن للقناة.", "Not yet observed": "لم يُرصد بعد", "Nothing has driven a framework change, so there is no episode to narrate.": "لم يدفع أي شيء بعد إلى تغيير في الإطار، لذا لا توجد حلقة لسردها.", "OHLC extracted from captured session market data.": "OHLC مستخرج من بيانات السوق المسجلة في الجلسة.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "قائمة الرصد وحالة المقترحات وقرارات السياسة ونتائج دورة الحياة.", "Observations": "الرصدات", "Observed Hz": "الهرتز المرصود", "Observed rate against declared rate": "المعدل المرصود مقابل المعدل المعلن", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "مساحة أسماء عالمية واحدة، تُحكَّم بأسبقية التسجيل. يُسجَّل المتنافس ولا يُهمَل بصمت.", "Open": "مفتوح", "Open risks": "المخاطر المفتوحة", "Open/high/low/close from captured tool output.": "الافتتاح/الأعلى/الأدنى/الإغلاق من مخرجات الأدوات المسجلة.", "Origin": "المصدر", "Outcome": "النتيجة", "PRODUCTION": "إنتاج", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "لكل حلقة: المُحفِّز والقرار والتغيير وما إذا أُغلقت الفجوة.", "Per-channel calibration state, freshness, and residual correction": "حالة المعايرة وحداثتها وتصحيح المتبقي لكل قناة", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "أدلة وقت التشغيل لكل مقطع. وجود وحدة لا يعني أن شيئًا يستدعيها.", "Pipeline": "المسار", "Pipeline evidence": "أدلة المسار", "Pipeline reachability": "إمكانية الوصول إلى المسار", "Plan": "الخطة", "Plan steps": "خطوات الخطة", "Plugin": "الملحق", "Plugin roster and trust": "قائمة الملحقات والثقة", "Plugins": "الملحقات", "Plugins by origin": "الإضافات حسب المصدر", "Plugins by trust class": "الإضافات حسب فئة الثقة", "Policy": "السياسة", "Policy decisions": "قرارات السياسة", "Positions & actions": "المراكز والإجراءات", "Posture": "الوضع", "Price action": "حركة السعر", "Proposal": "المقترح", "Proposal status": "حالة المقترح", "Proposed, not admitted": "مُقترح وغير مقبول", "Pulse": "النبض", "Quarantine feed": "تغذية الحجر", "Ratio": "النسبة", "Read live from the registry and trust ledger every cycle.": "يُقرأ مباشرة من السجل ودفتر الثقة في كل دورة.", "Recent episodes": "الحلقات الأخيرة", "Reclaim candidates": "مرشّحو الاسترجاع", "Reclaimable": "قابل للاسترجاع", "References & follow-ups": "المراجع والمتابعات", "References (entities)": "المراجع (الكيانات)", "Registry": "السجل", "Registry delta": "فرق السجل", "Registry version": "إصدار السجل", "Regressions": "الانحدارات", "Rejected": "المرفوض", "Representative observations, capped for quick scanning.": "رصدات تمثيلية، محدودة العدد للقراءة السريعة.", "Requirements": "المتطلبات", "Research lens": "منظور بحثي", "Residual": "المتبقي", "Runtime evidence": "دليل وقت التشغيل", "Sampled history per channel, newest on the right": "سجل العينات لكل قناة، الأحدث على اليمين", "Segment": "المقطع", "Segments by status": "الأجزاء حسب الحالة", "Selectable": "قابل للاختيار", "Selection delta": "فرق الاختيار", "Self-acquired": "مُكتسَب ذاتيًا", "Self-acquired plugins that are registered but unselectable or never once used.": "إضافات مُكتسَبة ذاتيًا مُسجَّلة لكنها غير قابلة للاختيار أو لم تُستخدم قطّ.", "Sentiment lens": "منظور المشاعر", "Series": "السلسلة", "Session analysis": "تحليل الجلسة", "Signal strength": "قوة الإشارة", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "إشارات على أن شيئًا نما بشكل خاطئ أو تم حجبه. تظهر أيًا كان التبويب المفتوح.", "Skipped slots": "الفتحات المتخطاة", "State": "الحالة", "Storyline and signal strength before drilling into positions and actions.": "السرد وقوة الإشارة قبل التوسع في المراكز والإجراءات.", "Streaming": "بث", "Suggested next steps": "الخطوات التالية المقترحة", "The line of investigation and where the open questions concentrate.": "خط البحث وأين تتركز الأسئلة المفتوحة.", "The narrative arc and how strongly themes are trending.": "قوس السرد ومدى قوة اتجاه الموضوعات.", "The world model asked for these capabilities and nothing took them up.": "طلب نموذج العالم هذه القدرات ولم يتبنّها شيء.", "Theme intensity": "شدة الموضوعات", "Themes": "الموضوعات", "This board reports how the framework changes itself. Nothing has been recorded yet.": "تُبلِّغ هذه اللوحة عن كيفية تغيير الإطار لنفسه. لم يُسجَّل أي شيء بعد.", "To": "إلى", "Tool": "الأداة", "Tool-name conflicts": "تعارضات أسماء الأدوات", "Tools": "الأدوات", "Transport": "النقل", "Transport, provenance and channel counts": "النقل والمنشأ وعدد القنوات", "Trust": "الثقة", "Trust accrual": "تراكم الثقة", "Trust class": "فئة الثقة", "Unselectable reclamation": "استرجاع غير القابل للاختيار", "VERIFIED": "مُتحقَّق", "Verified": "مُتحقَّق", "Verified by": "تم التحقق بواسطة", "Voices & concerns": "الأصوات والمخاوف", "Watchlist": "قائمة المتابعة", "What changed in the environment, and what the framework did about it.": "ما تغيّر في البيئة، وما فعله الإطار حيال ذلك.", "Which plugin owns which tool, and which capability that tool provides.": "أي ملحق يملك أي أداة، وأي قدرة توفرها تلك الأداة.", "Who/what is in the conversation, and the concerns still open.": "من/ما هو في المحادثة، والمخاوف التي لا تزال مفتوحة.", "Why": "السبب", "Why not admitted": "سبب عدم القبول", "Why this page is empty": "لماذا هذه الصفحة فارغة", "World-model driver": "مُشغِّل نموذج العالم", "Writable": "قابل للكتابة", "aborted": "مُلغى", "accruing": "قيد التراكم", "active": "نشط", "appeared": "ظهر", "armed": "مُسلّح", "assess_compatibility": "تقييم التوافق", "built_in": "مدمج", "capability_expand": "توسيع القدرة", "committed": "مُنجَز", "conformance": "المطابقة", "declared_fitness": "الملاءمة المُعلنة", "disable": "تعطيل", "disposed": "تم التخلص منه", "environment_probe": "مِجَس البيئة", "failed": "فشل", "frozen": "مُجمَّد", "gone": "اختفى", "idle": "خامل", "install": "تثبيت", "loading": "قيد التحميل", "manual": "يدوي", "moved": "انتقل", "new_unproven": "جديد وغير مُثبَت", "no": "لا", "no_evidence": "لا يوجد دليل", "none": "لا شيء", "not_admitted": "غير مقبول", "not_applicable": "غير منطبق", "observe_only": "المراقبة فقط", "observed_effect": "الأثر المرصود", "open": "مفتوح", "pending": "معلّق", "reload": "إعادة تحميل", "remove": "إزالة", "reopened": "أُعيد فتحه", "resolved": "تم الحل", "rollback": "تراجع", "runtime": "وقت التشغيل", "self_acquired": "مُكتسَب ذاتيًا", "still_open": "لا يزال مفتوحًا", "trusted": "موثوق", "unknown": "غير معروف", "unknown_tool": "أداة غير معروفة", "unloading": "قيد الإلغاء", "unscheduled": "غير مُجدول", "unverifiable": "غير قابل للتحقق", "unverified": "غير مُتحقَّق", "waiting": "في الانتظار", "watching": "يراقب", "wired": "موصول", "world_model": "نموذج العالم", "yes": "نعم"}, - ru: {"> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **Регрессия: закрытый пробел возобновился.** Эволюция, казавшаяся успешной, не удержалась. Это единственный вывод на этой панели, требующий немедленного внимания.", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **Только снимок.** Причинной истории для восстановления пока нет, поэтому хронология отсутствует, а не пуста. Причина указана в строке `Решения политики` под покрытием конвейера; снимок и таблица покрытия не затронуты.", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **Некоторые плагины заморожены из-за внутреннего дефекта.** Замороженный плагин по-прежнему сообщает `DRAFT`, а измерение доверия только *оценивает*, поэтому он остаётся выбираемым, пока не будет также снят с регистрации — см. столбец `Выбираемо`.", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **Уровень проверки: L2 (заявленная пригодность).** Снятое наблюдение означает, что кандидат *заявил* о предоставлении возможности, а не что возможность наблюдалась в работе. Проверка эффекта (L3) не подключена, поэтому ни одно закрытие на этой панели не следует считать доказанным.", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> Прежде чем появятся данные, должен завершиться хотя бы один цикл наблюдения. Если это сохраняется, проверьте, включён ли планировщик и что наблюдение `framework-evolution` активно и не отключено.", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> Эпизод записывается, когда наблюдение окружения приводит к решению о возможности. Ни одного не зафиксировано: либо система спокойна, либо конвейер останавливается раньше — вкладка **Конвейер** называет сегмент остановки и то, что его разблокирует.", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> Ничто не утилизирует их автоматически. Каждый занимает имя инструмента и присутствует в списке возможностей, не будучи выбираемым: реестр растёт в направлении, непригодном ни для одного требования.", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> Эти предложения не вошли ни в один конвейер, поэтому не отражены ни в одной записи решения или наблюдения. Их приём — вопрос конфигурации.", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "Отношение ниже 1,0 означает, что цикл выборки не выдерживает объявленный ритм.", "Acquisition authority": "Право на получение", "Acquisition lifecycle": "Жизненный цикл получения", "Action": "Действие", "After": "После", "An unverified declaration has its writable channels demoted to read-only.": "У непроверенного объявления записываемые каналы понижаются до только чтения.", "Approval": "Согласование", "Autonomous governance": "Автономное управление", "Autonomy": "Автономность", "Before": "До", "CANDIDATE": "Кандидат", "Calibrated at": "Калиброван", "Calibration health": "Состояние калибровки", "Calls": "Вызовы", "Calls (decisions)": "Рекомендации (решения)", "Candlestick": "Свечи", "Capability": "Возможность", "Capability adaptation": "Адаптация возможностей", "Capability observations": "Наблюдения возможностей", "Capability ownership": "Владение возможностями", "Capability topology": "Топология возможностей", "Change": "Изменение", "Channel": "Канал", "Channels": "Каналы", "Channels that have never been calibrated or whose calibration has expired are shown first.": "Каналы, которые никогда не калибровались или чья калибровка истекла, показаны первыми.", "Command": "Команда", "Commanded versus observed, best tracking first": "Заданное против наблюдаемого, лучшее отслеживание первым", "Composition": "Состав", "Concerns (open questions)": "Опасения (открытые вопросы)", "Confidence": "Уверенность", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "Подсчитано по всем отображаемым каналам. «У границы» — в пределах 5% от объявленного предела.", "Cycles run": "Выполнено циклов", "DRAFT": "Черновик", "Days since": "Дней с тех пор", "Decision": "Решение", "Decisions read as calls; action items as the execution checklist.": "Решения читаются как рекомендации; действия — как чек-лист исполнения.", "Declared Hz": "Объявл. Гц", "Desk brief": "Сводка деска", "Device": "Устройство", "Dropped samples": "Отброшенные образцы", "Each row names one blocked segment and the change that would unblock it.": "Каждая строка называет заблокированный сегмент и изменение, которое его разблокирует.", "Effect verification (L3)": "Проверка эффекта (L3)", "Entities as references, and recommended next prompts to advance the work.": "Сущности как ссылки и рекомендуемые следующие запросы.", "Entities in play and the open risks still to resolve.": "Задействованные сущности и нерешённые риски.", "Envelope, rate, staleness and quality observations · newest first": "Наблюдения по огибающей, частоте, устареванию и качеству · сначала новые", "Environment": "Окружение", "Environment to framework": "От окружения к фреймворку", "Environment, selected plugin tools, and orchestration order.": "Окружение, выбранные инструменты плагинов и порядок оркестрации.", "Error rate": "Частота ошибок", "Events paced out": "Событий подавлено", "Ever used": "Использовался", "Evidence": "Обоснование", "Evidence admission": "Приём данных", "Evolution": "Эволюция", "Evolution timeline": "Хронология эволюции", "Executable": "Исполнимо", "Execution checklist": "Чек-лист исполнения", "Extracted from this session's tool/file output (not model-generated).": "Извлечено из вывода инструментов/файлов этой сессии (не сгенерировано моделью).", "Failures": "Сбои", "Fiber": "Файбер", "Fiber state changes since the previous cycle, including load retries.": "Изменения состояния fiber с предыдущего цикла, включая повторные загрузки.", "Finance lens": "Финансовый ракурс", "Follow-ups": "Продолжения", "Framework change": "Изменение фреймворка", "Framework changes as they happened, from runtime probes.": "Изменения фреймворка в момент их появления, от рантайм-зондов.", "Framework evolution": "Эволюция фреймворка", "Framework size and how much of the evolution pipeline shows runtime evidence.": "Размер фреймворка и какая часть конвейера эволюции показывает свидетельства времени выполнения.", "From": "Из", "Frozen plugins": "Замороженные плагины", "Gap closure": "Закрытие пробела", "Halt": "Останов", "How closures are verified": "Как проверяются закрытия", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "Какую часть фреймворка он вырастил сам и какая часть конвейера показывает данные времени выполнения.", "How often each window sat inside, near, or outside its declared limits": "Как часто каждое окно было внутри, у границы или вне объявленных пределов", "Inquiry brief": "Сводка исследования", "Insights carded as evidence, capped for fast review.": "Инсайты как карточки-обоснования, ограничены для быстрого просмотра.", "Instruments & counterparties": "Инструменты и контрагенты", "Kept": "Оставлен", "Latest capability decision": "Последнее решение о возможностях", "Lifecycle records": "Записи жизненного цикла", "Lifecycle timeline": "Хронология жизненного цикла", "Lifecycle transitions": "Переходы жизненного цикла", "Line of inquiry": "Линия исследования", "Live activity": "Текущая активность", "Location": "Расположение", "Loop phase": "Фаза цикла", "Mean of each downsample window. Declared limits are listed per channel below.": "Среднее по каждому окну прореживания. Объявленные пределы указаны по каналам ниже.", "Model's reasoning": "Обоснование модели", "Mutation": "Изменение", "Narrative": "Сюжет", "Narrative pulse": "Нарративный пульс", "Needs attention": "Требует внимания", "Next recal due": "Следующая рекалибровка", "Next step": "Следующий шаг", "No causal history yet": "Причинной истории пока нет", "Normalized error": "Нормированная ошибка", "Normalized error is the residual as a share of the channel's declared span.": "Нормированная ошибка — остаток как доля объявленного диапазона канала.", "Not yet observed": "Ещё не наблюдалось", "Nothing has driven a framework change, so there is no episode to narrate.": "Ничто пока не вызвало изменения фреймворка, поэтому рассказывать не о чем.", "OHLC extracted from captured session market data.": "OHLC извлечён из рыночных данных, записанных в сессии.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "Очередь наблюдений, состояние предложений, решения политики и итоги жизненного цикла.", "Observations": "Наблюдения", "Observed Hz": "Наблюд. Гц", "Observed rate against declared rate": "Наблюдаемая частота против объявленной", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "Единое глобальное пространство имён, арбитраж по первому пришедшему. Претендент записывается, а не отбрасывается молча.", "Open": "Открыт", "Open risks": "Открытые риски", "Open/high/low/close from captured tool output.": "Открытие/максимум/минимум/закрытие из записанного вывода инструментов.", "Origin": "Источник", "Outcome": "Результат", "PRODUCTION": "Продакшн", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "По эпизодам: триггер, решение, изменение и закрылся ли пробел.", "Per-channel calibration state, freshness, and residual correction": "Состояние калибровки, актуальность и остаточная поправка по каналам", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "Свидетельства времени выполнения по сегментам. Наличие модуля не доказывает, что его кто-то вызывает.", "Pipeline": "Конвейер", "Pipeline evidence": "Свидетельства конвейера", "Pipeline reachability": "Достижимость конвейера", "Plan": "План", "Plan steps": "Шаги плана", "Plugin": "Плагин", "Plugin roster and trust": "Реестр плагинов и доверие", "Plugins": "Плагины", "Plugins by origin": "Плагины по происхождению", "Plugins by trust class": "Плагины по классу доверия", "Policy": "Политика", "Policy decisions": "Решения политики", "Positions & actions": "Позиции и действия", "Posture": "Состояние", "Price action": "Ценовое движение", "Proposal": "Предложение", "Proposal status": "Статус предложения", "Proposed, not admitted": "Предложено, не принято", "Pulse": "Пульс", "Quarantine feed": "Поток карантина", "Ratio": "Отношение", "Read live from the registry and trust ledger every cycle.": "Читается напрямую из реестра и журнала доверия каждый цикл.", "Recent episodes": "Недавние эпизоды", "Reclaim candidates": "Кандидаты на утилизацию", "Reclaimable": "Утилизируемо", "References & follow-ups": "Ссылки и продолжения", "References (entities)": "Ссылки (сущности)", "Registry": "Реестр", "Registry delta": "Изменение реестра", "Registry version": "Версия реестра", "Regressions": "Регрессии", "Rejected": "Отклонён", "Representative observations, capped for quick scanning.": "Показательные наблюдения, ограничены для быстрого просмотра.", "Requirements": "Требования", "Research lens": "Исследовательский ракурс", "Residual": "Остаток", "Runtime evidence": "Свидетельство времени выполнения", "Sampled history per channel, newest on the right": "История выборок по каналам, самое новое справа", "Segment": "Сегмент", "Segments by status": "Сегменты по статусу", "Selectable": "Выбираемый", "Selection delta": "Изменение выбора", "Self-acquired": "Самостоятельно получено", "Self-acquired plugins that are registered but unselectable or never once used.": "Самостоятельно полученные плагины, которые зарегистрированы, но невыбираемы или ни разу не использовались.", "Sentiment lens": "Ракурс тональности", "Series": "Серия", "Session analysis": "Анализ сессии", "Signal strength": "Сила сигнала", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "Признаки того, что что-то выросло неверно или было задержано. Показываются независимо от открытой вкладки.", "Skipped slots": "Пропущенные слоты", "State": "Состояние", "Storyline and signal strength before drilling into positions and actions.": "Сюжет и сила сигнала до перехода к позициям и действиям.", "Streaming": "Потоковая передача", "Suggested next steps": "Рекомендуемые следующие шаги", "The line of investigation and where the open questions concentrate.": "Линия исследования и где сосредоточены открытые вопросы.", "The narrative arc and how strongly themes are trending.": "Нарративная дуга и насколько сильно растут темы.", "The world model asked for these capabilities and nothing took them up.": "Модель мира запросила эти возможности, и никто их не принял.", "Theme intensity": "Интенсивность тем", "Themes": "Темы", "This board reports how the framework changes itself. Nothing has been recorded yet.": "Эта панель сообщает, как фреймворк изменяет сам себя. Пока ничего не записано.", "To": "В", "Tool": "Инструмент", "Tool-name conflicts": "Конфликты имён инструментов", "Tools": "Инструменты", "Transport": "Транспорт", "Transport, provenance and channel counts": "Транспорт, происхождение и число каналов", "Trust": "Доверие", "Trust accrual": "Накопление доверия", "Trust class": "Класс доверия", "Unselectable reclamation": "Утилизация невыбираемого", "VERIFIED": "Проверено", "Verified": "Проверено", "Verified by": "Подтверждено", "Voices & concerns": "Голоса и опасения", "Watchlist": "Список наблюдения", "What changed in the environment, and what the framework did about it.": "Что изменилось в окружении и что фреймворк с этим сделал.", "Which plugin owns which tool, and which capability that tool provides.": "Какой плагин владеет каким инструментом и какую возможность этот инструмент предоставляет.", "Who/what is in the conversation, and the concerns still open.": "Кто/что в разговоре и какие опасения остаются.", "Why": "Почему", "Why not admitted": "Причина отклонения", "Why this page is empty": "Почему эта страница пуста", "World-model driver": "Драйвер модели мира", "Writable": "Записываемый", "aborted": "Прервано", "accruing": "Накапливается", "active": "Активно", "appeared": "Появился", "armed": "Активно", "assess_compatibility": "Оценка совместимости", "built_in": "Встроенный", "capability_expand": "Расширение возможностей", "committed": "Завершено", "conformance": "Соответствие", "declared_fitness": "Заявленная пригодность", "disable": "Отключение", "disposed": "Освобождено", "environment_probe": "Зонд окружения", "failed": "Сбой", "frozen": "Заморожено", "gone": "Исчез", "idle": "Простой", "install": "Установка", "loading": "Загрузка", "manual": "Вручную", "moved": "Перешёл", "new_unproven": "Новое, непроверенное", "no": "Нет", "no_evidence": "Нет данных", "none": "Нет", "not_admitted": "Не принято", "not_applicable": "Неприменимо", "observe_only": "Только наблюдение", "observed_effect": "Наблюдаемый эффект", "open": "Открыто", "pending": "Ожидает", "reload": "Перезагрузка", "remove": "Удаление", "reopened": "Возобновлено", "resolved": "Закрыто", "rollback": "Откат", "runtime": "Среда выполнения", "self_acquired": "Самостоятельно получено", "still_open": "Всё ещё открыто", "trusted": "Доверенное", "unknown": "Неизвестно", "unknown_tool": "Неизвестный инструмент", "unloading": "Выгрузка", "unscheduled": "Не запланировано", "unverifiable": "Не проверяемо", "unverified": "Непроверенное", "waiting": "Ожидание", "watching": "Наблюдает", "wired": "Подключено", "world_model": "Модель мира", "yes": "Да"} + zh: {"> **No effect verdict has been recorded yet**, so there is no reward signal to measure. The rates above are blank rather than zero on purpose. An effect can only be verified when the requirement declared one, and only world-model-authored requirements carry an expected effect today.": "> **尚未记录任何效果判定**,因此没有可度量的奖励信号。上面的比率有意留空而非显示 0%。只有当需求声明了预期效果才可能验证,而目前只有世界模型撰写的需求带有预期效果。", "> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **回归:已闭合的缺口再次复发。** 一次看起来成功的演进并未站住。这是本看板上唯一需要立即处理的发现。", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **仅快照。** 目前尚无可重建的因果历史,因此时间线是「缺席」而非「空白」。原因由管道贯通度中的 `策略决策` 一行说明;实时快照与贯通度表本身不受影响。", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **部分插件因内部缺陷被冻结。** 冻结的插件仍报告 `DRAFT`,而信任维度只做「打分」,因此若未同时注销,它仍可被选中——请查看 `可被选中` 列。", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **验证层级:L2(声明式适配)。** 观测被退役,只意味着某个候选**声明**自己提供该能力,并不意味着该能力被观测到确实生效。效果验证(L3)尚未接线,因此本看板上的任何闭合都不应被读作「已证实」。", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> 需要至少完成一个观测周期才会有内容。若持续为空,请检查调度器是否启用、`framework-evolution` watch 是否已 armed 且未静音。", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> 当一次环境观测导向一次能力决策时,才会写下一条剧集。目前尚无记录——这既可能是系统本就安静,也可能是管道更早就断了:**管道**页签会指出它断在哪一段,以及什么能解除阻塞。", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> 目前没有任何机制自动回收它们。每一个都占着一个工具名、出现在能力列表里,却不可被选中——注册表朝着没有任何需求能用的方向增长。", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> 这些提议未进入任何管道,因此不会出现在任何决策记录或观测中。是否准入是一项配置选择。", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "比值低于 1.0 表示采样循环未能维持其声明的节奏。", "Abstained": "弃权", "Acquisition authority": "获取授权", "Acquisition lifecycle": "获取生命周期", "Action": "动作", "After": "变更后", "An unverified declaration has its writable channels demoted to read-only.": "未核验的声明,其可写通道会被降级为只读。", "Approval": "审批", "Autonomous governance": "自主治理", "Autonomy": "自主级别", "Before": "变更前", "CANDIDATE": "候选级", "Calibrated at": "校准时间", "Calibration health": "校准健康度", "Calls": "调用次数", "Calls (decisions)": "观点(决策)", "Candlestick": "K 线", "Capability": "能力", "Capability adaptation": "能力适配", "Capability observations": "能力观测", "Capability ownership": "能力归属", "Capability topology": "能力拓扑", "Change": "变化", "Channel": "通道", "Channels": "通道数", "Channels that have never been calibrated or whose calibration has expired are shown first.": "从未校准或校准已过期的通道排在最前。", "Command": "命令", "Commanded versus observed, best tracking first": "命令值与实测值对比,跟随最好者在前", "Composition": "组成", "Concerns (open questions)": "关切(待答问题)", "Confidence": "置信度", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "统计所有绘制通道。“接近”指处于声明边界的 5% 以内。", "Cycles run": "已运行周期", "DRAFT": "草稿级", "Days since": "距今天数", "Decision": "决策", "Decisions read as calls; action items as the execution checklist.": "决策即观点,行动项即执行清单。", "Declared Hz": "声明频率 (Hz)", "Desk brief": "交易台简报", "Device": "设备", "Dropped samples": "丢弃的样本", "Each row names one blocked segment and the change that would unblock it.": "每一行指出一个受阻环节,以及能解除阻塞的那项变更。", "Effect verification (L3)": "效果验证(L3)", "Effects declared": "已声明效果", "Entities as references, and recommended next prompts to advance the work.": "实体作为参考,并给出推进工作的后续追问。", "Entities in play and the open risks still to resolve.": "涉及的实体,以及尚未解决的敞口风险。", "Envelope, rate, staleness and quality observations · newest first": "包络、速率、失联与质量观测 · 最新在前", "Environment": "环境", "Environment to framework": "环境 → 框架", "Environment, selected plugin tools, and orchestration order.": "环境、已选插件工具及编排顺序。", "Error rate": "错误率", "Events paced out": "被配速抑制的事件", "Ever used": "是否用过", "Evidence": "证据", "Evidence admission": "证据准入", "Evolution": "演进", "Evolution timeline": "演进时间线", "Executable": "可执行", "Execution checklist": "执行清单", "Extracted from this session's tool/file output (not model-generated).": "数据来自本次会话的工具/文件产物(非模型生成)。", "Failures": "失败次数", "Fiber": "Fiber 状态", "Fiber state changes since the previous cycle, including load retries.": "自上一周期以来的 Fiber 状态变化,含加载重试。", "Finance lens": "金融视图", "Follow-ups": "后续事项", "Framework change": "框架变更", "Framework changes as they happened, from runtime probes.": "来自运行时探针的框架变更实况。", "Framework evolution": "框架演进", "Framework size and how much of the evolution pipeline shows runtime evidence.": "框架规模,以及演进管道中有多少环节呈现运行时证据。", "From": "从", "Frozen plugins": "已冻结插件", "Gap closure": "缺口闭合", "Halt": "可急停", "How closures are verified": "闭合是如何验证的", "How much of the effect signal is usable as feedback. This decides whether a learning policy is worth building.": "效果信号中有多少可真正用作反馈。这决定了是否值得构建学习策略。", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "框架中有多少是它自己长出来的,以及演进管道中有多少环节呈现运行时证据。", "How often each window sat inside, near, or outside its declared limits": "各窗口处于声明限值内、接近边界或越界的频次", "Inquiry brief": "研究简报", "Insights carded as evidence, capped for fast review.": "洞察以证据卡呈现,数量受限以便快速浏览。", "Instruments & counterparties": "标的与交易对手", "Kept": "保留", "Latest capability decision": "最新能力决策", "Lifecycle records": "生命周期记录", "Lifecycle timeline": "生命周期时间线", "Lifecycle transitions": "生命周期迁移", "Line of inquiry": "研究主线", "Live activity": "实时动态", "Location": "位置", "Loop phase": "循环阶段", "Mean of each downsample window. Declared limits are listed per channel below.": "每个降采样窗口的均值。各通道的声明限值见下方。", "Model's reasoning": "模型的推理", "Mutation": "变更", "Narrative": "叙事", "Narrative pulse": "叙事脉搏", "Needs attention": "需要关注", "Next recal due": "下次校准期限", "Next step": "下一步", "No causal history yet": "尚无因果历史", "Normalized error": "归一化误差", "Normalized error is the residual as a share of the channel's declared span.": "归一化误差是残差占该通道声明量程的比例。", "Not yet observed": "尚未观测", "Nothing has driven a framework change, so there is no episode to narrate.": "尚无任何事驱动过框架变更,因此没有可讲述的剧集。", "OHLC extracted from captured session market data.": "OHLC 提取自本次会话捕获的行情数据。", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "观测待办、提案状态、策略决策与生命周期结果。", "Observations": "观测数", "Observed Hz": "实测频率 (Hz)", "Observed rate against declared rate": "实测速率与声明速率对比", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "单一全局命名空间,先注册者胜。挑战者会被记录,绝不静默丢弃。", "Open": "已连接", "Open risks": "敞口风险", "Open/high/low/close from captured tool output.": "开/高/低/收,来自捕获的工具输出。", "Origin": "来源", "Outcome": "结果", "PRODUCTION": "生产级", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "逐条剧集:触发源、决策、变更,以及缺口是否闭合。", "Per-channel calibration state, freshness, and residual correction": "各通道的校准状态、时效性与残差校正", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "逐段运行时证据。模块存在并不等于有任何代码调用它。", "Pipeline": "管道", "Pipeline evidence": "管道证据", "Pipeline reachability": "管道贯通度", "Plan": "计划", "Plan steps": "计划步骤", "Plugin": "插件", "Plugin roster and trust": "插件名册与信任", "Plugins": "插件数", "Plugins by origin": "按来源分布的插件", "Plugins by trust class": "按信任等级分布的插件", "Policy": "策略", "Policy decisions": "策略决策", "Positions & actions": "持仓与操作", "Posture": "态势", "Price action": "价格行为", "Proposal": "提案", "Proposal status": "提案状态", "Proposed, not admitted": "已提议,未准入", "Pulse": "脉搏", "Quarantine feed": "隔离进料", "Ratio": "比值", "Read live from the registry and trust ledger every cycle.": "每个周期从注册表与信任账本实时读取。", "Recent episodes": "近期剧集", "Reclaim candidates": "可回收候选", "Reclaimable": "可回收", "References & follow-ups": "参考与后续", "References (entities)": "参考(实体)", "Registry": "注册表", "Registry delta": "注册表变化", "Registry version": "注册表版本", "Regressions": "回归", "Rejected": "被拒", "Representative observations, capped for quick scanning.": "代表性观察,数量受限以便快速浏览。", "Requirements": "能力需求", "Research lens": "研究视图", "Residual": "残差", "Reward signal bandwidth": "奖励信号带宽", "Runtime evidence": "运行时证据", "Sampled history per channel, newest on the right": "按通道的采样历史,最新在右侧", "Segment": "管道段", "Segments by status": "按状态分布的管道段", "Selectable": "可被选中", "Selection delta": "选择变化", "Self-acquired": "自获取", "Self-acquired plugins that are registered but unselectable or never once used.": "已注册但不可被选中、或从未被使用过的自获取插件。", "Sentiment lens": "情绪视图", "Series": "序列", "Session analysis": "会话分析", "Signal strength": "信号强度", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "表明某处长错了、或被扣下未放行的信号。无论打开哪个页签都会显示。", "Skipped slots": "跳过的采样点", "State": "状态", "Storyline and signal strength before drilling into positions and actions.": "先看叙事与信号强度,再深入持仓与操作。", "Streaming": "采样中", "Suggested next steps": "建议的下一步", "The line of investigation and where the open questions concentrate.": "研究主线,以及待答问题的集中之处。", "The narrative arc and how strongly themes are trending.": "叙事走向,以及主题的趋势强度。", "The world model asked for these capabilities and nothing took them up.": "世界模型请求了这些能力,但无人受理。", "Theme intensity": "主题强度", "Themes": "主题", "This board reports how the framework changes itself. Nothing has been recorded yet.": "本看板报告框架如何改变自身。目前尚无任何记录。", "To": "到", "Tool": "工具", "Tool-name conflicts": "工具名冲突", "Tools": "工具数", "Transport": "传输方式", "Transport, provenance and channel counts": "传输方式、来源与通道数量", "Trust": "信任级别", "Trust accrual": "信任累积", "Trust class": "信任语义", "Unselectable reclamation": "不可选回收", "Usable": "可用", "VERIFIED": "已验证级", "Verdicts": "判定数", "Verdicts by reason": "按原因分布的判定", "Verified": "已核验", "Verified by": "验证依据", "Voices & concerns": "声音与关切", "Watchlist": "关注列表", "What changed in the environment, and what the framework did about it.": "环境发生了什么变化,框架又为此做了什么。", "Which plugin owns which tool, and which capability that tool provides.": "哪个插件拥有哪个工具,以及该工具提供什么能力。", "Who/what is in the conversation, and the concerns still open.": "谁/什么在被讨论,以及尚未解决的关切。", "Why": "原因", "Why not admitted": "未准入原因", "Why this page is empty": "这个页面为何是空的", "World-model driver": "世界模型驱动器", "Writable": "可写", "aborted": "已中断", "accruing": "正在累积", "active": "运行中", "appeared": "新出现", "armed": "已就绪", "assess_compatibility": "评估兼容性", "built_in": "内置", "capability_expand": "扩展能力", "committed": "已定论", "conformance": "合规", "declared_fitness": "声明式适配", "disable": "停用", "disposed": "已释放", "effect_observed": "效果已观测", "environment_probe": "环境探测", "execution_failed": "执行失败", "expected_effect_absent": "预期效果未出现", "failed": "已失败", "frozen": "已冻结", "gone": "已消失", "idle": "空闲无变化", "install": "安装", "loading": "加载中", "manual": "人工", "moved": "已迁移", "new_unproven": "新,未验证", "no": "否", "no_evidence": "无证据", "no_expected_effect_declared": "未声明预期效果", "no_outcome_observed": "未观测到结果", "none": "无", "not_admitted": "未准入", "not_applicable": "不适用", "observe_only": "仅观察", "observed_effect": "观测效果", "open": "进行中", "pending": "待启", "reload": "重载", "remove": "移除", "reopened": "已复发", "resolved": "已闭合", "rollback": "回滚", "runtime": "运行时", "self_acquired": "自获取", "still_open": "仍未闭合", "tool_reported_no_effect": "工具未报告效果", "trusted": "已信任", "unknown": "未知", "unknown_tool": "未知工具", "unloading": "卸载中", "unscheduled": "未调度", "unverifiable": "无法核实", "unverified": "未验证", "waiting": "等待首个周期", "watching": "监视中", "wired": "已贯通", "world_model": "世界模型", "yes": "是"}, + fr: {"> **No effect verdict has been recorded yet**, so there is no reward signal to measure. The rates above are blank rather than zero on purpose. An effect can only be verified when the requirement declared one, and only world-model-authored requirements carry an expected effect today.": "> **Aucun verdict d'effet n'a encore été enregistré**, il n'y a donc aucun signal de récompense à mesurer. Les taux ci-dessus sont volontairement vides plutôt que nuls. Un effet ne peut être vérifié que si l'exigence en a déclaré un, et seules les exigences rédigées par le modèle du monde en portent aujourd'hui.", "> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **Régression : un écart comblé s'est reproduit.** Une évolution qui semblait réussie n'a pas tenu. C'est le seul constat de ce tableau qui exige une attention immédiate.", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **Instantané seulement.** Aucun historique causal à reconstruire pour l'instant : la chronologie est absente, non vide. La raison est indiquée par la ligne `Décisions de politique` sous la couverture du pipeline ; l'instantané et le tableau de couverture ne sont pas affectés.", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **Certains plugins sont gelés par un défaut interne.** Un plugin gelé signale toujours `DRAFT`, et la dimension de confiance ne fait que *noter*, donc il reste sélectionnable tant qu'il n'est pas également désenregistré — voir la colonne `Sélectionnable`.", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **Niveau de vérification : L2 (aptitude déclarée).** Une observation retirée signifie qu'un candidat a *déclaré* fournir la capacité, non que la capacité a été observée en fonctionnement. La vérification d'effet (L3) n'est pas câblée, donc aucune clôture de ce tableau ne doit être lue comme prouvée.", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> Un cycle d'observation doit s'achever avant qu'il y ait quoi que ce soit à montrer. Si cela persiste, vérifiez que le planificateur est actif et que la surveillance `framework-evolution` est armée et non silencée.", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> Un épisode est écrit lorsqu'une observation de l'environnement conduit à une décision de capacité. Aucun n'a été enregistré : soit le système est calme, soit le pipeline s'arrête plus tôt — l'onglet **Pipeline** nomme le segment où il s'arrête et ce qui le débloquerait.", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> Rien ne les récupère automatiquement. Chacun occupe un nom d'outil et figure dans la liste des capacités sans être sélectionnable : le registre grandit dans une direction qu'aucune exigence ne peut utiliser.", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> Ces propositions n'ont intégré aucun pipeline : elles n'apparaissent donc dans aucun enregistrement de décision ni observation. Les admettre est un choix de configuration.", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "Un ratio inférieur à 1,0 signifie que la boucle d’échantillonnage ne tient pas sa cadence déclarée.", "Abstained": "Abstention", "Acquisition authority": "Autorité d'acquisition", "Acquisition lifecycle": "Cycle de vie d'acquisition", "Action": "Action", "After": "Après", "An unverified declaration has its writable channels demoted to read-only.": "Une déclaration non vérifiée voit ses canaux inscriptibles rétrogradés en lecture seule.", "Approval": "Approbation", "Autonomous governance": "Gouvernance autonome", "Autonomy": "Autonomie", "Before": "Avant", "CANDIDATE": "Candidat", "Calibrated at": "Calibré le", "Calibration health": "État de calibration", "Calls": "Appels", "Calls (decisions)": "Recommandations (décisions)", "Candlestick": "Chandeliers", "Capability": "Capacité", "Capability adaptation": "Adaptation des capacités", "Capability observations": "Observations de capacités", "Capability ownership": "Propriété des capacités", "Capability topology": "Topologie des capacités", "Change": "Changement", "Channel": "Canal", "Channels": "Canaux", "Channels that have never been calibrated or whose calibration has expired are shown first.": "Les canaux jamais calibrés ou dont la calibration a expiré apparaissent en premier.", "Command": "Commande", "Commanded versus observed, best tracking first": "Commandé contre observé, meilleur suivi d’abord", "Composition": "Composition", "Concerns (open questions)": "Préoccupations (questions ouvertes)", "Confidence": "Confiance", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "Compté sur tous les canaux tracés. « près » signifie à moins de 5 % d’une borne déclarée.", "Cycles run": "Cycles exécutés", "DRAFT": "Brouillon", "Days since": "Jours écoulés", "Decision": "Décision", "Decisions read as calls; action items as the execution checklist.": "Les décisions se lisent comme des recommandations ; les actions comme la liste d’exécution.", "Declared Hz": "Hz déclarés", "Desk brief": "Note de desk", "Device": "Appareil", "Dropped samples": "Échantillons perdus", "Each row names one blocked segment and the change that would unblock it.": "Chaque ligne nomme un segment bloqué et le changement qui le débloquerait.", "Effect verification (L3)": "Vérification d'effet (L3)", "Effects declared": "Effets déclarés", "Entities as references, and recommended next prompts to advance the work.": "Entités comme références, et invites suivantes recommandées pour avancer.", "Entities in play and the open risks still to resolve.": "Entités concernées et risques ouverts à résoudre.", "Envelope, rate, staleness and quality observations · newest first": "Observations d’enveloppe, de débit, d’obsolescence et de qualité · les plus récentes d’abord", "Environment": "Environnement", "Environment to framework": "De l'environnement au framework", "Environment, selected plugin tools, and orchestration order.": "Environnement, outils de plugin sélectionnés et ordre d’orchestration.", "Error rate": "Taux d'erreur", "Events paced out": "Événements limités", "Ever used": "Déjà utilisé", "Evidence": "Preuve", "Evidence admission": "Admission des preuves", "Evolution": "Évolution", "Evolution timeline": "Chronologie de l'évolution", "Executable": "Exécutable", "Execution checklist": "Liste d’exécution", "Extracted from this session's tool/file output (not model-generated).": "Extrait des sorties d’outils/fichiers de cette session (non généré par le modèle).", "Failures": "Échecs", "Fiber": "Fibre", "Fiber state changes since the previous cycle, including load retries.": "Changements d'état de fiber depuis le cycle précédent, y compris les tentatives de chargement.", "Finance lens": "Vue finance", "Follow-ups": "Suivis", "Framework change": "Changement du framework", "Framework changes as they happened, from runtime probes.": "Changements du framework en temps réel, via les sondes d'exécution.", "Framework evolution": "Évolution du framework", "Framework size and how much of the evolution pipeline shows runtime evidence.": "Taille du framework et part du pipeline d'évolution qui présente des preuves d'exécution.", "From": "De", "Frozen plugins": "Plugins gelés", "Gap closure": "Clôture de l'écart", "Halt": "Arrêt", "How closures are verified": "Comment les clôtures sont vérifiées", "How much of the effect signal is usable as feedback. This decides whether a learning policy is worth building.": "Quelle part du signal d'effet est exploitable comme rétroaction. C'est ce qui détermine s'il vaut la peine de construire une politique d'apprentissage.", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "Quelle part du framework il a fait croître lui-même, et quelle part du pipeline présente des preuves d'exécution.", "How often each window sat inside, near, or outside its declared limits": "Fréquence à laquelle chaque fenêtre était dans, près de, ou hors de ses limites déclarées", "Inquiry brief": "Note d’enquête", "Insights carded as evidence, capped for fast review.": "Analyses présentées comme preuves, limitées pour une revue rapide.", "Instruments & counterparties": "Instruments et contreparties", "Kept": "Conservé", "Latest capability decision": "Dernière décision de capacité", "Lifecycle records": "Enregistrements de cycle de vie", "Lifecycle timeline": "Chronologie du cycle de vie", "Lifecycle transitions": "Transitions de cycle de vie", "Line of inquiry": "Ligne d’enquête", "Live activity": "Activité en direct", "Location": "Emplacement", "Loop phase": "Phase de boucle", "Mean of each downsample window. Declared limits are listed per channel below.": "Moyenne de chaque fenêtre de sous-échantillonnage. Les limites déclarées figurent par canal ci-dessous.", "Model's reasoning": "Raisonnement du modèle", "Mutation": "Mutation", "Narrative": "Récit", "Narrative pulse": "Pouls narratif", "Needs attention": "Requiert attention", "Next recal due": "Prochaine recalibration", "Next step": "Étape suivante", "No causal history yet": "Pas encore d'historique causal", "Normalized error": "Erreur normalisée", "Normalized error is the residual as a share of the channel's declared span.": "L’erreur normalisée est le résidu en proportion de l’étendue déclarée du canal.", "Not yet observed": "Pas encore observé", "Nothing has driven a framework change, so there is no episode to narrate.": "Rien n'a encore déclenché de changement du framework : il n'y a donc aucun épisode à raconter.", "OHLC extracted from captured session market data.": "OHLC extrait des données de marché capturées durant la session.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "File d’observations, état des propositions, décisions de politique et résultats du cycle de vie.", "Observations": "Observations", "Observed Hz": "Hz observés", "Observed rate against declared rate": "Débit observé par rapport au débit déclaré", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "Un espace de noms global unique, arbitré au premier arrivé. Le concurrent est enregistré, jamais supprimé en silence.", "Open": "Ouvert", "Open risks": "Risques ouverts", "Open/high/low/close from captured tool output.": "Ouverture/haut/bas/clôture issus des sorties d’outils capturées.", "Origin": "Origine", "Outcome": "Résultat", "PRODUCTION": "Production", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "Par épisode : le déclencheur, la décision, le changement, et si l'écart a été comblé.", "Per-channel calibration state, freshness, and residual correction": "État de calibration, fraîcheur et correction résiduelle par canal", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "Preuves d'exécution par segment. L'existence d'un module ne prouve pas qu'il soit appelé.", "Pipeline": "Pipeline", "Pipeline evidence": "Preuves du pipeline", "Pipeline reachability": "Accessibilité du pipeline", "Plan": "Plan", "Plan steps": "Étapes du plan", "Plugin": "Plugin", "Plugin roster and trust": "Registre des plugins et confiance", "Plugins": "Plugins", "Plugins by origin": "Plugins par origine", "Plugins by trust class": "Plugins par classe de confiance", "Policy": "Politique", "Policy decisions": "Décisions de politique", "Positions & actions": "Positions et actions", "Posture": "Posture", "Price action": "Action des prix", "Proposal": "Proposition", "Proposal status": "Statut de la proposition", "Proposed, not admitted": "Proposé, non admis", "Pulse": "Pouls", "Quarantine feed": "Flux de quarantaine", "Ratio": "Ratio", "Read live from the registry and trust ledger every cycle.": "Lu en direct depuis le registre et le registre de confiance à chaque cycle.", "Recent episodes": "Épisodes récents", "Reclaim candidates": "Candidats à la récupération", "Reclaimable": "Récupérable", "References & follow-ups": "Références et suivis", "References (entities)": "Références (entités)", "Registry": "Registre", "Registry delta": "Delta du registre", "Registry version": "Version du registre", "Regressions": "Régressions", "Rejected": "Rejeté", "Representative observations, capped for quick scanning.": "Observations représentatives, limitées pour une lecture rapide.", "Requirements": "Exigences", "Research lens": "Vue recherche", "Residual": "Résidu", "Reward signal bandwidth": "Bande passante du signal de récompense", "Runtime evidence": "Preuve d'exécution", "Sampled history per channel, newest on the right": "Historique échantillonné par canal, le plus récent à droite", "Segment": "Segment", "Segments by status": "Segments par statut", "Selectable": "Sélectionnable", "Selection delta": "Delta de sélection", "Self-acquired": "Auto-acquis", "Self-acquired plugins that are registered but unselectable or never once used.": "Plugins auto-acquis qui sont enregistrés mais non sélectionnables, ou jamais utilisés une seule fois.", "Sentiment lens": "Vue sentiment", "Series": "Série", "Session analysis": "Analyse de session", "Signal strength": "Force du signal", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "Signaux indiquant qu'une évolution a mal tourné ou a été retenue. Affichés quel que soit l'onglet ouvert.", "Skipped slots": "Créneaux manqués", "State": "État", "Storyline and signal strength before drilling into positions and actions.": "Récit et force du signal avant d’examiner positions et actions.", "Streaming": "Diffusion", "Suggested next steps": "Prochaines étapes suggérées", "The line of investigation and where the open questions concentrate.": "La ligne d’investigation et où se concentrent les questions ouvertes.", "The narrative arc and how strongly themes are trending.": "L’arc narratif et l’intensité des tendances thématiques.", "The world model asked for these capabilities and nothing took them up.": "Le modèle du monde a demandé ces capacités et personne ne les a prises en charge.", "Theme intensity": "Intensité des thèmes", "Themes": "Thèmes", "This board reports how the framework changes itself. Nothing has been recorded yet.": "Ce tableau rend compte de la façon dont le framework se modifie lui-même. Rien n'a encore été enregistré.", "To": "Vers", "Tool": "Outil", "Tool-name conflicts": "Conflits de noms d'outils", "Tools": "Outils", "Transport": "Transport", "Transport, provenance and channel counts": "Transport, provenance et nombre de canaux", "Trust": "Confiance", "Trust accrual": "Accumulation de confiance", "Trust class": "Classe de confiance", "Unselectable reclamation": "Récupération non sélectionnable", "Usable": "Exploitable", "VERIFIED": "Vérifié", "Verdicts": "Verdicts", "Verdicts by reason": "Verdicts par motif", "Verified": "Vérifié", "Verified by": "Vérifié par", "Voices & concerns": "Voix et préoccupations", "Watchlist": "Liste de suivi", "What changed in the environment, and what the framework did about it.": "Ce qui a changé dans l'environnement, et ce que le framework a fait en réponse.", "Which plugin owns which tool, and which capability that tool provides.": "Quel plugin possède quel outil, et quelle capacité cet outil fournit.", "Who/what is in the conversation, and the concerns still open.": "Qui/quoi est dans la conversation, et les préoccupations encore ouvertes.", "Why": "Pourquoi", "Why not admitted": "Motif de non-admission", "Why this page is empty": "Pourquoi cette page est vide", "World-model driver": "Pilote du modèle du monde", "Writable": "Inscriptible", "aborted": "Abandonné", "accruing": "En accumulation", "active": "Actif", "appeared": "Apparu", "armed": "Armé", "assess_compatibility": "Évaluer la compatibilité", "built_in": "Intégré", "capability_expand": "Étendre les capacités", "committed": "Conclu", "conformance": "Conformité", "declared_fitness": "Aptitude déclarée", "disable": "Désactiver", "disposed": "Libéré", "effect_observed": "Effet observé", "environment_probe": "Sonde d'environnement", "execution_failed": "Échec d'exécution", "expected_effect_absent": "Effet attendu absent", "failed": "Échoué", "frozen": "Gelé", "gone": "Disparu", "idle": "Au repos", "install": "Installer", "loading": "Chargement", "manual": "Manuel", "moved": "Déplacé", "new_unproven": "Nouveau, non éprouvé", "no": "Non", "no_evidence": "Aucune preuve", "no_expected_effect_declared": "Aucun effet attendu déclaré", "no_outcome_observed": "Aucun résultat observé", "none": "Aucun", "not_admitted": "Non admis", "not_applicable": "Sans objet", "observe_only": "Observer seulement", "observed_effect": "Effet observé", "open": "Ouvert", "pending": "En attente", "reload": "Recharger", "remove": "Supprimer", "reopened": "Réouvert", "resolved": "Résolu", "rollback": "Annuler", "runtime": "Exécution", "self_acquired": "Auto-acquis", "still_open": "Toujours ouvert", "tool_reported_no_effect": "L'outil n'a signalé aucun effet", "trusted": "De confiance", "unknown": "Inconnu", "unknown_tool": "Outil inconnu", "unloading": "Déchargement", "unscheduled": "Non planifié", "unverifiable": "Invérifiable", "unverified": "Non vérifié", "waiting": "En attente", "watching": "En surveillance", "wired": "Câblé", "world_model": "Modèle du monde", "yes": "Oui"}, + es: {"> **No effect verdict has been recorded yet**, so there is no reward signal to measure. The rates above are blank rather than zero on purpose. An effect can only be verified when the requirement declared one, and only world-model-authored requirements carry an expected effect today.": "> **Aún no se ha registrado ningún veredicto de efecto**, por lo que no hay señal de recompensa que medir. Las tasas anteriores están en blanco a propósito, no en cero. Un efecto solo puede verificarse si el requisito declaró uno, y hoy solo los requisitos redactados por el modelo del mundo lo llevan.", "> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **Regresión: una brecha cerrada ha vuelto a aparecer.** Una evolución que parecía exitosa no se sostuvo. Es el único hallazgo de este panel que exige atención inmediata.", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **Solo instantánea.** Todavía no hay historia causal que reconstruir, por lo que la cronología está ausente, no vacía. El motivo lo indica la fila `Decisiones de política` bajo la cobertura del pipeline; la instantánea y la tabla de cobertura no se ven afectadas.", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **Algunos plugins están congelados por un defecto interno.** Un plugin congelado sigue informando `DRAFT`, y la dimensión de confianza solo *puntúa*, por lo que permanece seleccionable a menos que también se desregistre — consulte la columna `Seleccionable`.", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **Nivel de verificación: L2 (aptitud declarada).** Una observación retirada significa que un candidato *declaró* que proporciona la capacidad, no que se observara funcionando. La verificación de efecto (L3) no está conectada, así que ningún cierre de este panel debe leerse como probado.", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> Debe completarse un ciclo de observación antes de que haya algo que mostrar. Si persiste, compruebe que el planificador está activo y que la vigilancia `framework-evolution` está armada y no silenciada.", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> Un episodio se escribe cuando una observación del entorno conduce a una decisión de capacidad. No se ha registrado ninguno: o el sistema está tranquilo o el pipeline se detiene antes — la pestaña **Pipeline** nombra el segmento donde se detiene y qué lo desbloquearía.", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> Nada los recupera automáticamente. Cada uno ocupa un nombre de herramienta y aparece en la lista de capacidades sin ser seleccionable: el registro crece en una dirección que ningún requisito puede usar.", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> Estas propuestas no entraron en ningún pipeline, por lo que no aparecen en ningún registro de decisión ni observación. Admitirlas es una elección de configuración.", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "Una relación inferior a 1,0 significa que el bucle de muestreo no mantiene su cadencia declarada.", "Abstained": "Abstenido", "Acquisition authority": "Autoridad de adquisición", "Acquisition lifecycle": "Ciclo de vida de adquisición", "Action": "Acción", "After": "Después", "An unverified declaration has its writable channels demoted to read-only.": "Una declaración no verificada degrada sus canales escribibles a solo lectura.", "Approval": "Aprobación", "Autonomous governance": "Gobernanza autónoma", "Autonomy": "Autonomía", "Before": "Antes", "CANDIDATE": "Candidato", "Calibrated at": "Calibrado el", "Calibration health": "Estado de calibración", "Calls": "Llamadas", "Calls (decisions)": "Recomendaciones (decisiones)", "Candlestick": "Velas", "Capability": "Capacidad", "Capability adaptation": "Adaptación de capacidades", "Capability observations": "Observaciones de capacidad", "Capability ownership": "Propiedad de capacidades", "Capability topology": "Topología de capacidades", "Change": "Cambio", "Channel": "Canal", "Channels": "Canales", "Channels that have never been calibrated or whose calibration has expired are shown first.": "Los canales nunca calibrados o con calibración vencida se muestran primero.", "Command": "Comando", "Commanded versus observed, best tracking first": "Comandado frente a observado, mejor seguimiento primero", "Composition": "Composición", "Concerns (open questions)": "Inquietudes (preguntas abiertas)", "Confidence": "Confianza", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "Contado en todos los canales graficados. «cerca» significa dentro del 5 % de un límite declarado.", "Cycles run": "Ciclos ejecutados", "DRAFT": "Borrador", "Days since": "Días desde", "Decision": "Decisión", "Decisions read as calls; action items as the execution checklist.": "Las decisiones se leen como recomendaciones; las acciones como la lista de ejecución.", "Declared Hz": "Hz declarados", "Desk brief": "Informe de mesa", "Device": "Dispositivo", "Dropped samples": "Muestras descartadas", "Each row names one blocked segment and the change that would unblock it.": "Cada fila nombra un segmento bloqueado y el cambio que lo desbloquearía.", "Effect verification (L3)": "Verificación de efecto (L3)", "Effects declared": "Efectos declarados", "Entities as references, and recommended next prompts to advance the work.": "Entidades como referencias y siguientes preguntas recomendadas para avanzar.", "Entities in play and the open risks still to resolve.": "Entidades implicadas y riesgos abiertos por resolver.", "Envelope, rate, staleness and quality observations · newest first": "Observaciones de envolvente, tasa, obsolescencia y calidad · las más recientes primero", "Environment": "Entorno", "Environment to framework": "Del entorno al framework", "Environment, selected plugin tools, and orchestration order.": "Entorno, herramientas de plugin seleccionadas y orden de orquestación.", "Error rate": "Tasa de error", "Events paced out": "Eventos limitados", "Ever used": "Alguna vez usado", "Evidence": "Evidencia", "Evidence admission": "Admisión de evidencia", "Evolution": "Evolución", "Evolution timeline": "Cronología de la evolución", "Executable": "Ejecutable", "Execution checklist": "Lista de ejecución", "Extracted from this session's tool/file output (not model-generated).": "Extraído de la salida de herramientas/archivos de esta sesión (no generado por el modelo).", "Failures": "Fallos", "Fiber": "Fibra", "Fiber state changes since the previous cycle, including load retries.": "Cambios de estado de fiber desde el ciclo anterior, incluidos los reintentos de carga.", "Finance lens": "Vista financiera", "Follow-ups": "Seguimientos", "Framework change": "Cambio del framework", "Framework changes as they happened, from runtime probes.": "Cambios del framework en tiempo real, desde sondas de ejecución.", "Framework evolution": "Evolución del framework", "Framework size and how much of the evolution pipeline shows runtime evidence.": "Tamaño del framework y qué parte del pipeline de evolución muestra evidencia en ejecución.", "From": "Desde", "Frozen plugins": "Plugins congelados", "Gap closure": "Cierre de la brecha", "Halt": "Parada", "How closures are verified": "Cómo se verifican los cierres", "How much of the effect signal is usable as feedback. This decides whether a learning policy is worth building.": "Cuánto de la señal de efecto es utilizable como retroalimentación. Esto decide si vale la pena construir una política de aprendizaje.", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "Cuánto del framework hizo crecer por sí mismo y cuánto del pipeline muestra evidencia de ejecución.", "How often each window sat inside, near, or outside its declared limits": "Con qué frecuencia cada ventana estuvo dentro, cerca o fuera de sus límites declarados", "Inquiry brief": "Informe de indagación", "Insights carded as evidence, capped for fast review.": "Hallazgos presentados como evidencia, limitados para revisión rápida.", "Instruments & counterparties": "Instrumentos y contrapartes", "Kept": "Conservado", "Latest capability decision": "Última decisión de capacidad", "Lifecycle records": "Registros de ciclo de vida", "Lifecycle timeline": "Cronología del ciclo de vida", "Lifecycle transitions": "Transiciones de ciclo de vida", "Line of inquiry": "Línea de indagación", "Live activity": "Actividad en vivo", "Location": "Ubicación", "Loop phase": "Fase del bucle", "Mean of each downsample window. Declared limits are listed per channel below.": "Media de cada ventana de submuestreo. Los límites declarados se listan por canal abajo.", "Model's reasoning": "Razonamiento del modelo", "Mutation": "Mutación", "Narrative": "Narrativa", "Narrative pulse": "Pulso narrativo", "Needs attention": "Requiere atención", "Next recal due": "Próxima recalibración", "Next step": "Siguiente paso", "No causal history yet": "Aún no hay historia causal", "Normalized error": "Error normalizado", "Normalized error is the residual as a share of the channel's declared span.": "El error normalizado es el residuo como fracción del rango declarado del canal.", "Not yet observed": "Aún no observado", "Nothing has driven a framework change, so there is no episode to narrate.": "Nada ha impulsado todavía un cambio del framework, por lo que no hay ningún episodio que narrar.", "OHLC extracted from captured session market data.": "OHLC extraído de los datos de mercado capturados en la sesión.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "Cola de observaciones, estado de propuestas, decisiones de política y resultados del ciclo de vida.", "Observations": "Observaciones", "Observed Hz": "Hz observados", "Observed rate against declared rate": "Tasa observada frente a la tasa declarada", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "Un único espacio de nombres global, arbitrado por orden de llegada. El aspirante queda registrado, nunca se descarta en silencio.", "Open": "Abierto", "Open risks": "Riesgos abiertos", "Open/high/low/close from captured tool output.": "Apertura/máximo/mínimo/cierre desde la salida de herramientas capturada.", "Origin": "Origen", "Outcome": "Resultado", "PRODUCTION": "Producción", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "Por episodio: el desencadenante, la decisión, el cambio y si la brecha se cerró.", "Per-channel calibration state, freshness, and residual correction": "Estado de calibración, vigencia y corrección residual por canal", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "Evidencia en ejecución por segmento. Que un módulo exista no prueba que algo lo invoque.", "Pipeline": "Pipeline", "Pipeline evidence": "Evidencia del pipeline", "Pipeline reachability": "Alcanzabilidad del pipeline", "Plan": "Plan", "Plan steps": "Pasos del plan", "Plugin": "Plugin", "Plugin roster and trust": "Registro de plugins y confianza", "Plugins": "Plugins", "Plugins by origin": "Plugins por origen", "Plugins by trust class": "Plugins por clase de confianza", "Policy": "Política", "Policy decisions": "Decisiones de política", "Positions & actions": "Posiciones y acciones", "Posture": "Postura", "Price action": "Acción del precio", "Proposal": "Propuesta", "Proposal status": "Estado de la propuesta", "Proposed, not admitted": "Propuesto, no admitido", "Pulse": "Pulso", "Quarantine feed": "Entrada de cuarentena", "Ratio": "Relación", "Read live from the registry and trust ledger every cycle.": "Leído en vivo del registro y del libro de confianza en cada ciclo.", "Recent episodes": "Episodios recientes", "Reclaim candidates": "Candidatos a recuperación", "Reclaimable": "Recuperable", "References & follow-ups": "Referencias y seguimientos", "References (entities)": "Referencias (entidades)", "Registry": "Registro", "Registry delta": "Delta del registro", "Registry version": "Versión del registro", "Regressions": "Regresiones", "Rejected": "Rechazado", "Representative observations, capped for quick scanning.": "Observaciones representativas, limitadas para lectura rápida.", "Requirements": "Requisitos", "Research lens": "Vista de investigación", "Residual": "Residuo", "Reward signal bandwidth": "Ancho de banda de la señal de recompensa", "Runtime evidence": "Evidencia en ejecución", "Sampled history per channel, newest on the right": "Historial muestreado por canal, el más reciente a la derecha", "Segment": "Segmento", "Segments by status": "Segmentos por estado", "Selectable": "Seleccionable", "Selection delta": "Delta de selección", "Self-acquired": "Autoadquirido", "Self-acquired plugins that are registered but unselectable or never once used.": "Plugins autoadquiridos que están registrados pero no son seleccionables, o nunca se han usado.", "Sentiment lens": "Vista de sentimiento", "Series": "Serie", "Session analysis": "Análisis de sesión", "Signal strength": "Fuerza de la señal", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "Señales de que algo creció mal o fue retenido. Se muestran independientemente de la pestaña abierta.", "Skipped slots": "Ranuras omitidas", "State": "Estado", "Storyline and signal strength before drilling into positions and actions.": "Narrativa y fuerza de la señal antes de entrar en posiciones y acciones.", "Streaming": "Transmisión", "Suggested next steps": "Próximos pasos sugeridos", "The line of investigation and where the open questions concentrate.": "La línea de investigación y dónde se concentran las preguntas abiertas.", "The narrative arc and how strongly themes are trending.": "El arco narrativo y con qué fuerza se mueven los temas.", "The world model asked for these capabilities and nothing took them up.": "El modelo del mundo pidió estas capacidades y nada las asumió.", "Theme intensity": "Intensidad temática", "Themes": "Temas", "This board reports how the framework changes itself. Nothing has been recorded yet.": "Este panel informa de cómo el framework se modifica a sí mismo. Todavía no se ha registrado nada.", "To": "Hasta", "Tool": "Herramienta", "Tool-name conflicts": "Conflictos de nombres de herramientas", "Tools": "Herramientas", "Transport": "Transporte", "Transport, provenance and channel counts": "Transporte, procedencia y número de canales", "Trust": "Confianza", "Trust accrual": "Acumulación de confianza", "Trust class": "Clase de confianza", "Unselectable reclamation": "Recuperación no seleccionable", "Usable": "Utilizable", "VERIFIED": "Verificado", "Verdicts": "Veredictos", "Verdicts by reason": "Veredictos por motivo", "Verified": "Verificado", "Verified by": "Verificado por", "Voices & concerns": "Voces e inquietudes", "Watchlist": "Lista de seguimiento", "What changed in the environment, and what the framework did about it.": "Qué cambió en el entorno y qué hizo el framework al respecto.", "Which plugin owns which tool, and which capability that tool provides.": "Qué plugin posee qué herramienta y qué capacidad proporciona esa herramienta.", "Who/what is in the conversation, and the concerns still open.": "Quién/qué está en la conversación y las inquietudes aún abiertas.", "Why": "Por qué", "Why not admitted": "Motivo de no admisión", "Why this page is empty": "Por qué esta página está vacía", "World-model driver": "Controlador del modelo del mundo", "Writable": "Escribible", "aborted": "Abortado", "accruing": "Acumulando", "active": "Activo", "appeared": "Apareció", "armed": "Armado", "assess_compatibility": "Evaluar compatibilidad", "built_in": "Integrado", "capability_expand": "Ampliar capacidad", "committed": "Concluido", "conformance": "Conformidad", "declared_fitness": "Aptitud declarada", "disable": "Desactivar", "disposed": "Liberado", "effect_observed": "Efecto observado", "environment_probe": "Sonda de entorno", "execution_failed": "Ejecución fallida", "expected_effect_absent": "Efecto esperado ausente", "failed": "Fallido", "frozen": "Congelado", "gone": "Desapareció", "idle": "Inactivo", "install": "Instalar", "loading": "Cargando", "manual": "Manual", "moved": "Se movió", "new_unproven": "Nuevo, no probado", "no": "No", "no_evidence": "Sin evidencia", "no_expected_effect_declared": "Sin efecto esperado declarado", "no_outcome_observed": "Sin resultado observado", "none": "Ninguno", "not_admitted": "No admitido", "not_applicable": "No aplicable", "observe_only": "Solo observar", "observed_effect": "Efecto observado", "open": "Abierto", "pending": "Pendiente", "reload": "Recargar", "remove": "Eliminar", "reopened": "Reabierto", "resolved": "Resuelto", "rollback": "Revertir", "runtime": "Tiempo de ejecución", "self_acquired": "Autoadquirido", "still_open": "Aún abierto", "tool_reported_no_effect": "La herramienta no informó efecto", "trusted": "De confianza", "unknown": "Desconocido", "unknown_tool": "Herramienta desconocida", "unloading": "Descargando", "unscheduled": "No planificado", "unverifiable": "No verificable", "unverified": "No verificado", "waiting": "En espera", "watching": "Vigilando", "wired": "Conectado", "world_model": "Modelo del mundo", "yes": "Sí"}, + ar: {"> **No effect verdict has been recorded yet**, so there is no reward signal to measure. The rates above are blank rather than zero on purpose. An effect can only be verified when the requirement declared one, and only world-model-authored requirements carry an expected effect today.": "> **لم يُسجَّل أي حكم على الأثر بعد**، لذا لا توجد إشارة مكافأة لقياسها. النسب أعلاه فارغة عن قصد وليست صفرًا. لا يمكن التحقق من الأثر إلا إذا أعلنه المطلب، واليوم لا تحمل الأثر المتوقع سوى المطالب التي كتبها نموذج العالم.", "> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **انحدار: فجوة أُغلقت عادت للظهور.** تطوّر بدا ناجحًا لم يصمد. هذا هو الاكتشاف الوحيد في هذه اللوحة الذي يستدعي انتباهًا فوريًا.", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **لقطة فقط.** لا يوجد بعد تاريخ سببي لإعادة بنائه، لذا فالخط الزمني غائب وليس فارغًا. السبب مبيَّن في صف `قرارات السياسة` تحت تغطية المسار؛ اللقطة الحيّة وجدول التغطية غير متأثرين.", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **بعض الإضافات مُجمَّدة بسبب خلل داخلي.** الإضافة المُجمَّدة لا تزال تُبلِّغ `DRAFT`، وبُعد الثقة يقوم بالتقييم فقط، لذا تبقى قابلة للاختيار إلا إذا أُلغي تسجيلها أيضًا — راجع عمود `قابل للاختيار`.", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **مستوى التحقق: L2 (الملاءمة المُعلنة).** سحب الرصد يعني أن مرشّحًا *أعلن* أنه يوفّر القدرة، لا أن القدرة رُصدت وهي تعمل. التحقق من الأثر (L3) غير موصول، لذا لا ينبغي قراءة أي إغلاق في هذه اللوحة كأمر مُثبَت.", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> يجب أن تكتمل دورة مراقبة واحدة قبل ظهور أي محتوى. إذا استمر ذلك، تحقّق من تمكين المُجدول وأن مراقبة `framework-evolution` مُسلّحة وغير مكتومة.", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> تُكتب الحلقة عندما يؤدي رصد للبيئة إلى قرار بشأن قدرة. لم يُسجَّل أي منها، وهذا يعني إمّا نظامًا هادئًا أو مسارًا يتوقف قبل ذلك — تبويب **المسار** يحدّد الجزء الذي يتوقف عنده وما الذي يزيل التعطيل.", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> لا شيء يستعيدها تلقائيًا. كل واحدة تحتجز اسم أداة وتظهر في قائمة القدرات دون أن تكون قابلة للاختيار، فينمو السجل في اتجاه لا يمكن لأي مطلب استخدامه.", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> لم تدخل هذه المقترحات أي مسار، لذا لا تظهر في أي سجل قرار أو رصد. قبولها خيار في الإعدادات.", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "نسبة أقل من 1.0 تعني أن حلقة أخذ العينات لا تحافظ على وتيرتها المعلنة.", "Abstained": "امتناع", "Acquisition authority": "سلطة الاكتساب", "Acquisition lifecycle": "دورة حياة الاكتساب", "Action": "الإجراء", "After": "بعد", "An unverified declaration has its writable channels demoted to read-only.": "الإعلان غير المُتحقَّق منه تُخفَّض قنواته القابلة للكتابة إلى القراءة فقط.", "Approval": "الموافقة", "Autonomous governance": "الحكم الذاتي", "Autonomy": "الاستقلالية", "Before": "قبل", "CANDIDATE": "مرشّح", "Calibrated at": "تاريخ المعايرة", "Calibration health": "سلامة المعايرة", "Calls": "الاستدعاءات", "Calls (decisions)": "التوصيات (القرارات)", "Candlestick": "الشموع", "Capability": "القدرة", "Capability adaptation": "تكييف القدرات", "Capability observations": "رصد القدرات", "Capability ownership": "ملكية القدرات", "Capability topology": "طوبولوجيا القدرات", "Change": "التغيير", "Channel": "القناة", "Channels": "القنوات", "Channels that have never been calibrated or whose calibration has expired are shown first.": "تظهر أولاً القنوات التي لم تُعاير قط أو التي انتهت صلاحية معايرتها.", "Command": "الأمر", "Commanded versus observed, best tracking first": "المأمور مقابل المرصود، الأفضل تتبعاً أولاً", "Composition": "التركيب", "Concerns (open questions)": "المخاوف (أسئلة مفتوحة)", "Confidence": "الثقة", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "محسوب على كل قناة مرسومة. \"قريب\" تعني داخل 5% من حد معلن.", "Cycles run": "الدورات المنفَّذة", "DRAFT": "مسوّدة", "Days since": "الأيام المنقضية", "Decision": "القرار", "Decisions read as calls; action items as the execution checklist.": "القرارات تُقرأ كتوصيات؛ والإجراءات كقائمة تنفيذ.", "Declared Hz": "الهرتز المعلن", "Desk brief": "موجز المكتب", "Device": "الجهاز", "Dropped samples": "العينات المفقودة", "Each row names one blocked segment and the change that would unblock it.": "كل صف يحدّد جزءًا معطَّلًا والتغيير الذي يزيل التعطيل.", "Effect verification (L3)": "التحقق من الأثر (L3)", "Effects declared": "الآثار المُعلنة", "Entities as references, and recommended next prompts to advance the work.": "الكيانات كمراجع، والمطالبات التالية الموصى بها لدفع العمل.", "Entities in play and the open risks still to resolve.": "الكيانات المعنية والمخاطر المفتوحة التي لم تُحل.", "Envelope, rate, staleness and quality observations · newest first": "رصدات المغلف والمعدل والتقادم والجودة · الأحدث أولاً", "Environment": "البيئة", "Environment to framework": "من البيئة إلى الإطار", "Environment, selected plugin tools, and orchestration order.": "البيئة والأدوات المختارة وترتيب التنسيق.", "Error rate": "معدل الأخطاء", "Events paced out": "الأحداث المُقيَّدة", "Ever used": "استُخدم سابقًا", "Evidence": "الدليل", "Evidence admission": "قبول الأدلة", "Evolution": "التطور", "Evolution timeline": "الخط الزمني للتطور", "Executable": "قابل للتنفيذ", "Execution checklist": "قائمة التنفيذ", "Extracted from this session's tool/file output (not model-generated).": "مستخرج من مخرجات الأدوات/الملفات في هذه الجلسة (ليس من إنشاء النموذج).", "Failures": "الأعطال", "Fiber": "الخيط", "Fiber state changes since the previous cycle, including load retries.": "تغييرات حالة الـ fiber منذ الدورة السابقة، بما في ذلك محاولات التحميل.", "Finance lens": "منظور مالي", "Follow-ups": "المتابعات", "Framework change": "تغيير الإطار", "Framework changes as they happened, from runtime probes.": "تغييرات الإطار لحظة حدوثها، من مجسّات وقت التشغيل.", "Framework evolution": "تطور الإطار", "Framework size and how much of the evolution pipeline shows runtime evidence.": "حجم الإطار ومقدار ما يُظهره مسار التطور من أدلة وقت التشغيل.", "From": "من", "Frozen plugins": "الإضافات المُجمَّدة", "Gap closure": "إغلاق الفجوة", "Halt": "إيقاف", "How closures are verified": "كيف يُتحقَّق من الإغلاقات", "How much of the effect signal is usable as feedback. This decides whether a learning policy is worth building.": "ما مقدار إشارة الأثر القابل للاستخدام كتغذية راجعة. هذا يحدّد ما إذا كان بناء سياسة تعلّم يستحق العناء.", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "ما مقدار ما نمّاه الإطار بنفسه، وما مقدار المسار الذي يُظهر أدلة وقت التشغيل.", "How often each window sat inside, near, or outside its declared limits": "عدد المرات التي كانت فيها كل نافذة داخل حدودها المعلنة أو قريبة منها أو خارجها", "Inquiry brief": "موجز الاستقصاء", "Insights carded as evidence, capped for fast review.": "الرؤى معروضة كأدلة، ومحدودة العدد للمراجعة السريعة.", "Instruments & counterparties": "الأدوات والأطراف المقابلة", "Kept": "المحتفظ به", "Latest capability decision": "أحدث قرار للقدرات", "Lifecycle records": "سجلات دورة الحياة", "Lifecycle timeline": "الخط الزمني لدورة الحياة", "Lifecycle transitions": "انتقالات دورة الحياة", "Line of inquiry": "خط الاستقصاء", "Live activity": "النشاط المباشر", "Location": "الموقع", "Loop phase": "مرحلة الحلقة", "Mean of each downsample window. Declared limits are listed per channel below.": "متوسط كل نافذة تخفيض للعينات. الحدود المعلنة مدرجة لكل قناة أدناه.", "Model's reasoning": "استدلال النموذج", "Mutation": "التغيير", "Narrative": "السرد", "Narrative pulse": "نبض السرد", "Needs attention": "يستدعي الانتباه", "Next recal due": "موعد إعادة المعايرة", "Next step": "الخطوة التالية", "No causal history yet": "لا يوجد تاريخ سببي بعد", "Normalized error": "الخطأ المعياري", "Normalized error is the residual as a share of the channel's declared span.": "الخطأ المعياري هو المتبقي كنسبة من المدى المعلن للقناة.", "Not yet observed": "لم يُرصد بعد", "Nothing has driven a framework change, so there is no episode to narrate.": "لم يدفع أي شيء بعد إلى تغيير في الإطار، لذا لا توجد حلقة لسردها.", "OHLC extracted from captured session market data.": "OHLC مستخرج من بيانات السوق المسجلة في الجلسة.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "قائمة الرصد وحالة المقترحات وقرارات السياسة ونتائج دورة الحياة.", "Observations": "الرصدات", "Observed Hz": "الهرتز المرصود", "Observed rate against declared rate": "المعدل المرصود مقابل المعدل المعلن", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "مساحة أسماء عالمية واحدة، تُحكَّم بأسبقية التسجيل. يُسجَّل المتنافس ولا يُهمَل بصمت.", "Open": "مفتوح", "Open risks": "المخاطر المفتوحة", "Open/high/low/close from captured tool output.": "الافتتاح/الأعلى/الأدنى/الإغلاق من مخرجات الأدوات المسجلة.", "Origin": "المصدر", "Outcome": "النتيجة", "PRODUCTION": "إنتاج", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "لكل حلقة: المُحفِّز والقرار والتغيير وما إذا أُغلقت الفجوة.", "Per-channel calibration state, freshness, and residual correction": "حالة المعايرة وحداثتها وتصحيح المتبقي لكل قناة", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "أدلة وقت التشغيل لكل مقطع. وجود وحدة لا يعني أن شيئًا يستدعيها.", "Pipeline": "المسار", "Pipeline evidence": "أدلة المسار", "Pipeline reachability": "إمكانية الوصول إلى المسار", "Plan": "الخطة", "Plan steps": "خطوات الخطة", "Plugin": "الملحق", "Plugin roster and trust": "قائمة الملحقات والثقة", "Plugins": "الملحقات", "Plugins by origin": "الإضافات حسب المصدر", "Plugins by trust class": "الإضافات حسب فئة الثقة", "Policy": "السياسة", "Policy decisions": "قرارات السياسة", "Positions & actions": "المراكز والإجراءات", "Posture": "الوضع", "Price action": "حركة السعر", "Proposal": "المقترح", "Proposal status": "حالة المقترح", "Proposed, not admitted": "مُقترح وغير مقبول", "Pulse": "النبض", "Quarantine feed": "تغذية الحجر", "Ratio": "النسبة", "Read live from the registry and trust ledger every cycle.": "يُقرأ مباشرة من السجل ودفتر الثقة في كل دورة.", "Recent episodes": "الحلقات الأخيرة", "Reclaim candidates": "مرشّحو الاسترجاع", "Reclaimable": "قابل للاسترجاع", "References & follow-ups": "المراجع والمتابعات", "References (entities)": "المراجع (الكيانات)", "Registry": "السجل", "Registry delta": "فرق السجل", "Registry version": "إصدار السجل", "Regressions": "الانحدارات", "Rejected": "المرفوض", "Representative observations, capped for quick scanning.": "رصدات تمثيلية، محدودة العدد للقراءة السريعة.", "Requirements": "المتطلبات", "Research lens": "منظور بحثي", "Residual": "المتبقي", "Reward signal bandwidth": "نطاق إشارة المكافأة", "Runtime evidence": "دليل وقت التشغيل", "Sampled history per channel, newest on the right": "سجل العينات لكل قناة، الأحدث على اليمين", "Segment": "المقطع", "Segments by status": "الأجزاء حسب الحالة", "Selectable": "قابل للاختيار", "Selection delta": "فرق الاختيار", "Self-acquired": "مُكتسَب ذاتيًا", "Self-acquired plugins that are registered but unselectable or never once used.": "إضافات مُكتسَبة ذاتيًا مُسجَّلة لكنها غير قابلة للاختيار أو لم تُستخدم قطّ.", "Sentiment lens": "منظور المشاعر", "Series": "السلسلة", "Session analysis": "تحليل الجلسة", "Signal strength": "قوة الإشارة", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "إشارات على أن شيئًا نما بشكل خاطئ أو تم حجبه. تظهر أيًا كان التبويب المفتوح.", "Skipped slots": "الفتحات المتخطاة", "State": "الحالة", "Storyline and signal strength before drilling into positions and actions.": "السرد وقوة الإشارة قبل التوسع في المراكز والإجراءات.", "Streaming": "بث", "Suggested next steps": "الخطوات التالية المقترحة", "The line of investigation and where the open questions concentrate.": "خط البحث وأين تتركز الأسئلة المفتوحة.", "The narrative arc and how strongly themes are trending.": "قوس السرد ومدى قوة اتجاه الموضوعات.", "The world model asked for these capabilities and nothing took them up.": "طلب نموذج العالم هذه القدرات ولم يتبنّها شيء.", "Theme intensity": "شدة الموضوعات", "Themes": "الموضوعات", "This board reports how the framework changes itself. Nothing has been recorded yet.": "تُبلِّغ هذه اللوحة عن كيفية تغيير الإطار لنفسه. لم يُسجَّل أي شيء بعد.", "To": "إلى", "Tool": "الأداة", "Tool-name conflicts": "تعارضات أسماء الأدوات", "Tools": "الأدوات", "Transport": "النقل", "Transport, provenance and channel counts": "النقل والمنشأ وعدد القنوات", "Trust": "الثقة", "Trust accrual": "تراكم الثقة", "Trust class": "فئة الثقة", "Unselectable reclamation": "استرجاع غير القابل للاختيار", "Usable": "قابل للاستخدام", "VERIFIED": "مُتحقَّق", "Verdicts": "الأحكام", "Verdicts by reason": "الأحكام حسب السبب", "Verified": "مُتحقَّق", "Verified by": "تم التحقق بواسطة", "Voices & concerns": "الأصوات والمخاوف", "Watchlist": "قائمة المتابعة", "What changed in the environment, and what the framework did about it.": "ما تغيّر في البيئة، وما فعله الإطار حيال ذلك.", "Which plugin owns which tool, and which capability that tool provides.": "أي ملحق يملك أي أداة، وأي قدرة توفرها تلك الأداة.", "Who/what is in the conversation, and the concerns still open.": "من/ما هو في المحادثة، والمخاوف التي لا تزال مفتوحة.", "Why": "السبب", "Why not admitted": "سبب عدم القبول", "Why this page is empty": "لماذا هذه الصفحة فارغة", "World-model driver": "مُشغِّل نموذج العالم", "Writable": "قابل للكتابة", "aborted": "مُلغى", "accruing": "قيد التراكم", "active": "نشط", "appeared": "ظهر", "armed": "مُسلّح", "assess_compatibility": "تقييم التوافق", "built_in": "مدمج", "capability_expand": "توسيع القدرة", "committed": "مُنجَز", "conformance": "المطابقة", "declared_fitness": "الملاءمة المُعلنة", "disable": "تعطيل", "disposed": "تم التخلص منه", "effect_observed": "تم رصد الأثر", "environment_probe": "مِجَس البيئة", "execution_failed": "فشل التنفيذ", "expected_effect_absent": "الأثر المتوقع غائب", "failed": "فشل", "frozen": "مُجمَّد", "gone": "اختفى", "idle": "خامل", "install": "تثبيت", "loading": "قيد التحميل", "manual": "يدوي", "moved": "انتقل", "new_unproven": "جديد وغير مُثبَت", "no": "لا", "no_evidence": "لا يوجد دليل", "no_expected_effect_declared": "لم يُعلَن أثر متوقع", "no_outcome_observed": "لم يُرصد أي ناتج", "none": "لا شيء", "not_admitted": "غير مقبول", "not_applicable": "غير منطبق", "observe_only": "المراقبة فقط", "observed_effect": "الأثر المرصود", "open": "مفتوح", "pending": "معلّق", "reload": "إعادة تحميل", "remove": "إزالة", "reopened": "أُعيد فتحه", "resolved": "تم الحل", "rollback": "تراجع", "runtime": "وقت التشغيل", "self_acquired": "مُكتسَب ذاتيًا", "still_open": "لا يزال مفتوحًا", "tool_reported_no_effect": "الأداة لم تُبلِّغ عن أثر", "trusted": "موثوق", "unknown": "غير معروف", "unknown_tool": "أداة غير معروفة", "unloading": "قيد الإلغاء", "unscheduled": "غير مُجدول", "unverifiable": "غير قابل للتحقق", "unverified": "غير مُتحقَّق", "waiting": "في الانتظار", "watching": "يراقب", "wired": "موصول", "world_model": "نموذج العالم", "yes": "نعم"}, + ru: {"> **No effect verdict has been recorded yet**, so there is no reward signal to measure. The rates above are blank rather than zero on purpose. An effect can only be verified when the requirement declared one, and only world-model-authored requirements carry an expected effect today.": "> **Ни одного заключения об эффекте пока не записано**, поэтому измерять нечего. Показатели выше намеренно пусты, а не равны нулю. Эффект можно проверить только если требование его заявило, а сегодня заявленный эффект несут лишь требования, составленные моделью мира.", "> **Regression: a closed gap has recurred.** An evolution that looked successful did not hold. This is the one finding on this board that warrants immediate attention.": "> **Регрессия: закрытый пробел возобновился.** Эволюция, казавшаяся успешной, не удержалась. Это единственный вывод на этой панели, требующий немедленного внимания.", "> **Snapshot only.** There is no causal history to rebuild yet, so the timeline is absent rather than empty. Why is stated by the `Policy decisions` row under pipeline reachability; the live snapshot and the reachability table itself are unaffected.": "> **Только снимок.** Причинной истории для восстановления пока нет, поэтому хронология отсутствует, а не пуста. Причина указана в строке `Решения политики` под покрытием конвейера; снимок и таблица покрытия не затронуты.", "> **Some plugins are frozen by an internal defect.** A frozen plugin still reports `DRAFT`, and the trust dimension only *scores*, so it stays selectable unless it is also unregistered — check the `Selectable` column.": "> **Некоторые плагины заморожены из-за внутреннего дефекта.** Замороженный плагин по-прежнему сообщает `DRAFT`, а измерение доверия только *оценивает*, поэтому он остаётся выбираемым, пока не будет также снят с регистрации — см. столбец `Выбираемо`.", "> **Verification tier: L2 (declared fitness).** A retired observation means a candidate *declared* it provides the capability, not that the capability was observed to work. Effect verification (L3) is not wired yet, so no closure on this board should be read as proven.": "> **Уровень проверки: L2 (заявленная пригодность).** Снятое наблюдение означает, что кандидат *заявил* о предоставлении возможности, а не что возможность наблюдалась в работе. Проверка эффекта (L3) не подключена, поэтому ни одно закрытие на этой панели не следует считать доказанным.", "> A watch has to complete one cycle before there is anything to show. If this persists, check that the scheduler is enabled and that the `framework-evolution` watch is armed and not muted.": "> Прежде чем появятся данные, должен завершиться хотя бы один цикл наблюдения. Если это сохраняется, проверьте, включён ли планировщик и что наблюдение `framework-evolution` активно и не отключено.", "> An episode is written when an environment observation leads to a capability decision. None has been recorded, which is either a quiet system or a pipeline that stops earlier — the **Pipeline** tab names the segment where it stops, and what would unblock it.": "> Эпизод записывается, когда наблюдение окружения приводит к решению о возможности. Ни одного не зафиксировано: либо система спокойна, либо конвейер останавливается раньше — вкладка **Конвейер** называет сегмент остановки и то, что его разблокирует.", "> Nothing reclaims these automatically. Each holds a tool name and appears in the capability list without being selectable, so the registry grows in a direction no requirement can use.": "> Ничто не утилизирует их автоматически. Каждый занимает имя инструмента и присутствует в списке возможностей, не будучи выбираемым: реестр растёт в направлении, непригодном ни для одного требования.", "> These proposals entered no pipeline, so they appear in no decision record and no observation. Admitting them is a configuration choice.": "> Эти предложения не вошли ни в один конвейер, поэтому не отражены ни в одной записи решения или наблюдения. Их приём — вопрос конфигурации.", "A ratio below 1.0 means the sampling loop is not keeping its declared cadence.": "Отношение ниже 1,0 означает, что цикл выборки не выдерживает объявленный ритм.", "Abstained": "Воздержалось", "Acquisition authority": "Право на получение", "Acquisition lifecycle": "Жизненный цикл получения", "Action": "Действие", "After": "После", "An unverified declaration has its writable channels demoted to read-only.": "У непроверенного объявления записываемые каналы понижаются до только чтения.", "Approval": "Согласование", "Autonomous governance": "Автономное управление", "Autonomy": "Автономность", "Before": "До", "CANDIDATE": "Кандидат", "Calibrated at": "Калиброван", "Calibration health": "Состояние калибровки", "Calls": "Вызовы", "Calls (decisions)": "Рекомендации (решения)", "Candlestick": "Свечи", "Capability": "Возможность", "Capability adaptation": "Адаптация возможностей", "Capability observations": "Наблюдения возможностей", "Capability ownership": "Владение возможностями", "Capability topology": "Топология возможностей", "Change": "Изменение", "Channel": "Канал", "Channels": "Каналы", "Channels that have never been calibrated or whose calibration has expired are shown first.": "Каналы, которые никогда не калибровались или чья калибровка истекла, показаны первыми.", "Command": "Команда", "Commanded versus observed, best tracking first": "Заданное против наблюдаемого, лучшее отслеживание первым", "Composition": "Состав", "Concerns (open questions)": "Опасения (открытые вопросы)", "Confidence": "Уверенность", "Counted across every charted channel. 'near' means within 5% of a declared bound.": "Подсчитано по всем отображаемым каналам. «У границы» — в пределах 5% от объявленного предела.", "Cycles run": "Выполнено циклов", "DRAFT": "Черновик", "Days since": "Дней с тех пор", "Decision": "Решение", "Decisions read as calls; action items as the execution checklist.": "Решения читаются как рекомендации; действия — как чек-лист исполнения.", "Declared Hz": "Объявл. Гц", "Desk brief": "Сводка деска", "Device": "Устройство", "Dropped samples": "Отброшенные образцы", "Each row names one blocked segment and the change that would unblock it.": "Каждая строка называет заблокированный сегмент и изменение, которое его разблокирует.", "Effect verification (L3)": "Проверка эффекта (L3)", "Effects declared": "Заявлено эффектов", "Entities as references, and recommended next prompts to advance the work.": "Сущности как ссылки и рекомендуемые следующие запросы.", "Entities in play and the open risks still to resolve.": "Задействованные сущности и нерешённые риски.", "Envelope, rate, staleness and quality observations · newest first": "Наблюдения по огибающей, частоте, устареванию и качеству · сначала новые", "Environment": "Окружение", "Environment to framework": "От окружения к фреймворку", "Environment, selected plugin tools, and orchestration order.": "Окружение, выбранные инструменты плагинов и порядок оркестрации.", "Error rate": "Частота ошибок", "Events paced out": "Событий подавлено", "Ever used": "Использовался", "Evidence": "Обоснование", "Evidence admission": "Приём данных", "Evolution": "Эволюция", "Evolution timeline": "Хронология эволюции", "Executable": "Исполнимо", "Execution checklist": "Чек-лист исполнения", "Extracted from this session's tool/file output (not model-generated).": "Извлечено из вывода инструментов/файлов этой сессии (не сгенерировано моделью).", "Failures": "Сбои", "Fiber": "Файбер", "Fiber state changes since the previous cycle, including load retries.": "Изменения состояния fiber с предыдущего цикла, включая повторные загрузки.", "Finance lens": "Финансовый ракурс", "Follow-ups": "Продолжения", "Framework change": "Изменение фреймворка", "Framework changes as they happened, from runtime probes.": "Изменения фреймворка в момент их появления, от рантайм-зондов.", "Framework evolution": "Эволюция фреймворка", "Framework size and how much of the evolution pipeline shows runtime evidence.": "Размер фреймворка и какая часть конвейера эволюции показывает свидетельства времени выполнения.", "From": "Из", "Frozen plugins": "Замороженные плагины", "Gap closure": "Закрытие пробела", "Halt": "Останов", "How closures are verified": "Как проверяются закрытия", "How much of the effect signal is usable as feedback. This decides whether a learning policy is worth building.": "Какая часть сигнала об эффекте пригодна как обратная связь. Это определяет, стоит ли строить обучающую политику.", "How much of the framework it grew itself, and how much of the pipeline shows runtime evidence.": "Какую часть фреймворка он вырастил сам и какая часть конвейера показывает данные времени выполнения.", "How often each window sat inside, near, or outside its declared limits": "Как часто каждое окно было внутри, у границы или вне объявленных пределов", "Inquiry brief": "Сводка исследования", "Insights carded as evidence, capped for fast review.": "Инсайты как карточки-обоснования, ограничены для быстрого просмотра.", "Instruments & counterparties": "Инструменты и контрагенты", "Kept": "Оставлен", "Latest capability decision": "Последнее решение о возможностях", "Lifecycle records": "Записи жизненного цикла", "Lifecycle timeline": "Хронология жизненного цикла", "Lifecycle transitions": "Переходы жизненного цикла", "Line of inquiry": "Линия исследования", "Live activity": "Текущая активность", "Location": "Расположение", "Loop phase": "Фаза цикла", "Mean of each downsample window. Declared limits are listed per channel below.": "Среднее по каждому окну прореживания. Объявленные пределы указаны по каналам ниже.", "Model's reasoning": "Обоснование модели", "Mutation": "Изменение", "Narrative": "Сюжет", "Narrative pulse": "Нарративный пульс", "Needs attention": "Требует внимания", "Next recal due": "Следующая рекалибровка", "Next step": "Следующий шаг", "No causal history yet": "Причинной истории пока нет", "Normalized error": "Нормированная ошибка", "Normalized error is the residual as a share of the channel's declared span.": "Нормированная ошибка — остаток как доля объявленного диапазона канала.", "Not yet observed": "Ещё не наблюдалось", "Nothing has driven a framework change, so there is no episode to narrate.": "Ничто пока не вызвало изменения фреймворка, поэтому рассказывать не о чем.", "OHLC extracted from captured session market data.": "OHLC извлечён из рыночных данных, записанных в сессии.", "Observation backlog, proposal state, policy decisions, and lifecycle outcomes.": "Очередь наблюдений, состояние предложений, решения политики и итоги жизненного цикла.", "Observations": "Наблюдения", "Observed Hz": "Наблюд. Гц", "Observed rate against declared rate": "Наблюдаемая частота против объявленной", "One global namespace, arbitrated first-wins. The challenger is recorded, never silently dropped.": "Единое глобальное пространство имён, арбитраж по первому пришедшему. Претендент записывается, а не отбрасывается молча.", "Open": "Открыт", "Open risks": "Открытые риски", "Open/high/low/close from captured tool output.": "Открытие/максимум/минимум/закрытие из записанного вывода инструментов.", "Origin": "Источник", "Outcome": "Результат", "PRODUCTION": "Продакшн", "Per episode: the trigger, the decision, the change, and whether the gap closed.": "По эпизодам: триггер, решение, изменение и закрылся ли пробел.", "Per-channel calibration state, freshness, and residual correction": "Состояние калибровки, актуальность и остаточная поправка по каналам", "Per-segment runtime evidence. A module existing is not evidence that anything calls it.": "Свидетельства времени выполнения по сегментам. Наличие модуля не доказывает, что его кто-то вызывает.", "Pipeline": "Конвейер", "Pipeline evidence": "Свидетельства конвейера", "Pipeline reachability": "Достижимость конвейера", "Plan": "План", "Plan steps": "Шаги плана", "Plugin": "Плагин", "Plugin roster and trust": "Реестр плагинов и доверие", "Plugins": "Плагины", "Plugins by origin": "Плагины по происхождению", "Plugins by trust class": "Плагины по классу доверия", "Policy": "Политика", "Policy decisions": "Решения политики", "Positions & actions": "Позиции и действия", "Posture": "Состояние", "Price action": "Ценовое движение", "Proposal": "Предложение", "Proposal status": "Статус предложения", "Proposed, not admitted": "Предложено, не принято", "Pulse": "Пульс", "Quarantine feed": "Поток карантина", "Ratio": "Отношение", "Read live from the registry and trust ledger every cycle.": "Читается напрямую из реестра и журнала доверия каждый цикл.", "Recent episodes": "Недавние эпизоды", "Reclaim candidates": "Кандидаты на утилизацию", "Reclaimable": "Утилизируемо", "References & follow-ups": "Ссылки и продолжения", "References (entities)": "Ссылки (сущности)", "Registry": "Реестр", "Registry delta": "Изменение реестра", "Registry version": "Версия реестра", "Regressions": "Регрессии", "Rejected": "Отклонён", "Representative observations, capped for quick scanning.": "Показательные наблюдения, ограничены для быстрого просмотра.", "Requirements": "Требования", "Research lens": "Исследовательский ракурс", "Residual": "Остаток", "Reward signal bandwidth": "Пропускная способность сигнала вознаграждения", "Runtime evidence": "Свидетельство времени выполнения", "Sampled history per channel, newest on the right": "История выборок по каналам, самое новое справа", "Segment": "Сегмент", "Segments by status": "Сегменты по статусу", "Selectable": "Выбираемый", "Selection delta": "Изменение выбора", "Self-acquired": "Самостоятельно получено", "Self-acquired plugins that are registered but unselectable or never once used.": "Самостоятельно полученные плагины, которые зарегистрированы, но невыбираемы или ни разу не использовались.", "Sentiment lens": "Ракурс тональности", "Series": "Серия", "Session analysis": "Анализ сессии", "Signal strength": "Сила сигнала", "Signals that something grew wrong, or was withheld. Shown regardless of the open tab.": "Признаки того, что что-то выросло неверно или было задержано. Показываются независимо от открытой вкладки.", "Skipped slots": "Пропущенные слоты", "State": "Состояние", "Storyline and signal strength before drilling into positions and actions.": "Сюжет и сила сигнала до перехода к позициям и действиям.", "Streaming": "Потоковая передача", "Suggested next steps": "Рекомендуемые следующие шаги", "The line of investigation and where the open questions concentrate.": "Линия исследования и где сосредоточены открытые вопросы.", "The narrative arc and how strongly themes are trending.": "Нарративная дуга и насколько сильно растут темы.", "The world model asked for these capabilities and nothing took them up.": "Модель мира запросила эти возможности, и никто их не принял.", "Theme intensity": "Интенсивность тем", "Themes": "Темы", "This board reports how the framework changes itself. Nothing has been recorded yet.": "Эта панель сообщает, как фреймворк изменяет сам себя. Пока ничего не записано.", "To": "В", "Tool": "Инструмент", "Tool-name conflicts": "Конфликты имён инструментов", "Tools": "Инструменты", "Transport": "Транспорт", "Transport, provenance and channel counts": "Транспорт, происхождение и число каналов", "Trust": "Доверие", "Trust accrual": "Накопление доверия", "Trust class": "Класс доверия", "Unselectable reclamation": "Утилизация невыбираемого", "Usable": "Пригодно", "VERIFIED": "Проверено", "Verdicts": "Заключений", "Verdicts by reason": "Заключения по причине", "Verified": "Проверено", "Verified by": "Подтверждено", "Voices & concerns": "Голоса и опасения", "Watchlist": "Список наблюдения", "What changed in the environment, and what the framework did about it.": "Что изменилось в окружении и что фреймворк с этим сделал.", "Which plugin owns which tool, and which capability that tool provides.": "Какой плагин владеет каким инструментом и какую возможность этот инструмент предоставляет.", "Who/what is in the conversation, and the concerns still open.": "Кто/что в разговоре и какие опасения остаются.", "Why": "Почему", "Why not admitted": "Причина отклонения", "Why this page is empty": "Почему эта страница пуста", "World-model driver": "Драйвер модели мира", "Writable": "Записываемый", "aborted": "Прервано", "accruing": "Накапливается", "active": "Активно", "appeared": "Появился", "armed": "Активно", "assess_compatibility": "Оценка совместимости", "built_in": "Встроенный", "capability_expand": "Расширение возможностей", "committed": "Завершено", "conformance": "Соответствие", "declared_fitness": "Заявленная пригодность", "disable": "Отключение", "disposed": "Освобождено", "effect_observed": "Эффект наблюдался", "environment_probe": "Зонд окружения", "execution_failed": "Сбой выполнения", "expected_effect_absent": "Ожидаемый эффект отсутствует", "failed": "Сбой", "frozen": "Заморожено", "gone": "Исчез", "idle": "Простой", "install": "Установка", "loading": "Загрузка", "manual": "Вручную", "moved": "Перешёл", "new_unproven": "Новое, непроверенное", "no": "Нет", "no_evidence": "Нет данных", "no_expected_effect_declared": "Ожидаемый эффект не заявлен", "no_outcome_observed": "Результат не наблюдался", "none": "Нет", "not_admitted": "Не принято", "not_applicable": "Неприменимо", "observe_only": "Только наблюдение", "observed_effect": "Наблюдаемый эффект", "open": "Открыто", "pending": "Ожидает", "reload": "Перезагрузка", "remove": "Удаление", "reopened": "Возобновлено", "resolved": "Закрыто", "rollback": "Откат", "runtime": "Среда выполнения", "self_acquired": "Самостоятельно получено", "still_open": "Всё ещё открыто", "tool_reported_no_effect": "Инструмент не сообщил об эффекте", "trusted": "Доверенное", "unknown": "Неизвестно", "unknown_tool": "Неизвестный инструмент", "unloading": "Выгрузка", "unscheduled": "Не запланировано", "unverifiable": "Не проверяемо", "unverified": "Непроверенное", "waiting": "Ожидание", "watching": "Наблюдает", "wired": "Подключено", "world_model": "Модель мира", "yes": "Да"} }; Object.keys(I18N).concat(Object.keys(I18N_PATCH), Object.keys(I18N_TEMPLATES)) .filter((lang, at, all) => all.indexOf(lang) === at) diff --git a/src/leapflow/dashboard/templates.py b/src/leapflow/dashboard/templates.py index 79ee1007..a483142f 100644 --- a/src/leapflow/dashboard/templates.py +++ b/src/leapflow/dashboard/templates.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """YAML template rendering into a validated ViewSpec (the SDUI authoring layer). Templates are authored in YAML per scenario and compiled at runtime into a diff --git a/src/leapflow/dashboard/templates/evolution.yaml b/src/leapflow/dashboard/templates/evolution.yaml index c17d5c4a..d78fb74d 100644 --- a/src/leapflow/dashboard/templates/evolution.yaml +++ b/src/leapflow/dashboard/templates/evolution.yaml @@ -310,6 +310,50 @@ layout: - key: next_step label: "Next step" + - type: Section + when: evolution.reward_bandwidth + props: + title: "Reward signal bandwidth" + subtitle: "How much of the effect signal is usable as feedback. This decides whether a learning policy is worth building." + children: + - type: Row + props: + variant: metrics + children: + - type: Gauge + props: + label: "Usable" + value: "{{ evolution.reward_bandwidth.usable_rate }}" + - type: Gauge + props: + label: "Abstained" + value: "{{ evolution.reward_bandwidth.abstain_rate }}" + - type: Stat + props: + label: "Verdicts" + value: "{{ evolution.reward_bandwidth.total }}" + - type: Gauge + props: + label: "Effects declared" + value: "{{ evolution.reward_bandwidth.declared_rate }}" + # Absence of a signal is not a healthy signal. With no verdicts an + # abstain rate of 0% would read as "almost nothing abstains", which is + # the opposite of the truth. + - type: Markdown + when: evolution.reward_bandwidth.absent + props: + text: >- + > **No effect verdict has been recorded yet**, so there is no reward + signal to measure. The rates above are blank rather than zero on + purpose. An effect can only be verified when the requirement declared + one, and only world-model-authored requirements carry an expected + effect today. + - type: BarChart + when: evolution.reward_bandwidth.by_reason + props: + title: "Verdicts by reason" + bind: evolution.reward_bandwidth.by_reason + # Traces come from probes at points no store retains: a registry # mutation, a trust transition, a teacher proposal. Kept separate from # the episode timeline on purpose -- these answer "what just happened", diff --git a/src/leapflow/dashboard/viewspec.py b/src/leapflow/dashboard/viewspec.py index 6574a8f9..af9e036e 100644 --- a/src/leapflow/dashboard/viewspec.py +++ b/src/leapflow/dashboard/viewspec.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """ViewSpec: the declarative, validated UI contract for the dashboard (SDUI). A ViewSpec is a JSON-serializable tree of components drawn from a fixed, diff --git a/src/leapflow/domain/__init__.py b/src/leapflow/domain/__init__.py index 88160bfa..062d08e7 100644 --- a/src/leapflow/domain/__init__.py +++ b/src/leapflow/domain/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shared domain model — zero-dependency data types used across all layers.""" from leapflow.domain.capability_requirement import ( diff --git a/src/leapflow/domain/adaptation_verdict.py b/src/leapflow/domain/adaptation_verdict.py new file mode 100644 index 00000000..58f4aec3 --- /dev/null +++ b/src/leapflow/domain/adaptation_verdict.py @@ -0,0 +1,203 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""What the teacher concluded an environment change warrants. + +The world model is asked what *action* the evidence supports, never who is at fault. +Those are not the same question and conflating them suppresses the case that most needs +answering: when an application upgrades, the incumbent implementation was not written +wrongly -- it was right for the old version -- yet a new adapter may still be the only +way forward. A prompt that asks "is the implementation wrong?" gets "no" and nothing +happens. + +So the answer space is the set of things the system can actually do about a change, +ordered by cost: + +* ``absorb`` the retry or semantic-addressing layer already handles it; the capability + set does not change. Cheapest, and the correct answer most of the time. +* ``rebind`` another installed plugin already covers the new environment; name it. +* ``acquire`` nothing covers it, so a new implementation is warranted. **The only + verdict that leads to code being written**, and therefore the only one + that becomes an :class:`EvolutionIntent`. +* ``escalate`` it needs a human -- a scope, a credential, a decision the agent cannot + make for itself. + +Every verdict carries ``knowledge``, and that is mandatory rather than optional. A +verdict without it teaches the student nothing, so the teacher would have done no useful +work even when its judgement was correct. Distilling what the environment now looks like +is the cheapest way to adapt and the reason this type exists at all: three of the four +verdicts change nothing except what the student knows. + +A verdict is a *hypothesis with a recommendation*. It carries no authorisation: an +``acquire`` still passes validation, approval, sandboxing and trust exactly as an +``unknown_tool`` signal does, and an ``escalate`` produces a message rather than an +action. +""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Literal + +from leapflow.domain.evolution_intent import ( + EvolutionIntent, + RiskLevel, + is_capability_name, +) + +#: The action space. Not an open string: a verdict outside this set has no consumer, and +#: silently ignoring one would look identical to the teacher having nothing to say. +AdaptationAction = Literal["absorb", "rebind", "acquire", "escalate"] + +ADAPTATION_ACTIONS: frozenset[str] = frozenset({"absorb", "rebind", "acquire", "escalate"}) + +#: The only verdict that results in code being written. +ACQUIRE: str = "acquire" + + +@dataclass(frozen=True) +class AdaptationVerdict: + """One teacher conclusion about one capability, with what the student should know.""" + + verdict_id: str + action: AdaptationAction + capability: str + #: What the student should know as a result. Rendered into its context, so it must + #: read as a statement about the world rather than an instruction to the framework. + knowledge: str + rationale: str = "" + confidence: float = 0.0 + #: For ``rebind``, the plugin or tool that should serve this capability instead. + #: For ``escalate``, what the human has to do. Empty otherwise. + target: str = "" + #: Only consulted for ``acquire``, and still clamped downstream by the trusted + #: caller: a model cannot widen the ceiling of what it asks to have built. + max_risk_level: RiskLevel = "read_only" + expected_effect: str = "" + target_affordance: str = "" + evidence_ids: tuple[str, ...] = field(default_factory=tuple) + created_at: float = 0.0 + + @classmethod + def create( + cls, + action: str, + capability: str, + knowledge: str, + *, + rationale: str = "", + confidence: float = 0.0, + target: str = "", + max_risk_level: RiskLevel = "read_only", + expected_effect: str = "", + target_affordance: str = "", + evidence_ids: Any = None, + verdict_id: str = "", + created_at: float | None = None, + ) -> AdaptationVerdict: + """Build a normalised verdict, refusing the shapes that cannot be acted on. + + Rejects rather than repairs. A verdict outside the action space, without a + capability, or without knowledge has no consumer -- and quietly dropping it + downstream would be indistinguishable from the teacher having said nothing, + which is exactly the failure mode that made a whole pipeline look idle while it + was in fact producing on every session. + """ + normalized_action = str(action or "").strip().lower() + if normalized_action not in ADAPTATION_ACTIONS: + raise ValueError( + f"action must be one of {sorted(ADAPTATION_ACTIONS)}, got {action!r}" + ) + normalized_capability = str(capability or "").strip() + if not is_capability_name(normalized_capability): + raise ValueError( + "capability must be a short dotted name such as 'chat.reply', " + f"got {capability!r}" + ) + distilled = str(knowledge or "").strip() + if not distilled: + raise ValueError( + "knowledge is required: a verdict that teaches the student nothing " + "leaves the teacher with no effect even when its judgement is right" + ) + return cls( + verdict_id=verdict_id or f"adv-{uuid.uuid4().hex}", + action=normalized_action, # type: ignore[arg-type] + capability=normalized_capability, + knowledge=distilled, + rationale=str(rationale or ""), + confidence=max(0.0, min(1.0, float(confidence))), + target=str(target or "").strip(), + max_risk_level=max_risk_level, + expected_effect=str(expected_effect or ""), + target_affordance=str(target_affordance or ""), + evidence_ids=tuple(str(item) for item in (evidence_ids or ()) if str(item)), + created_at=time.time() if created_at is None else float(created_at), + ) + + @property + def writes_code(self) -> bool: + """Whether acting on this verdict would generate an implementation.""" + return self.action == ACQUIRE + + def to_intent(self) -> EvolutionIntent | None: + """Derive the acquisition intent, or ``None`` for the other three verdicts. + + Derivation rather than a parallel field, so an ``EvolutionIntent`` can only ever + exist because a verdict asked for one. Keeping the two independent is how "I + recommend doing X" and "I want a new capability" get mixed into one object, and + then a recommendation to *rebind* silently queues an acquisition. + + The requested risk level is carried through **unclamped**. Clamping here as well + looked safer and destroyed the audit trail: the single clamp point downstream + records the original request only when it differs from what was granted, so + pre-clamping made the two equal and an approver could no longer see that the + model had asked for more than it got. One clamp, one place. + """ + if not self.writes_code: + return None + return EvolutionIntent.create( + self.capability, + # ``knowledge`` states what is true about the environment, which is exactly + # what an intent's hypothesis is for. ``rationale`` answers a different + # question -- why acquire rather than something cheaper -- and belongs in + # its own field, where the approver reads it. + self.knowledge, + confidence=self.confidence, + target_affordance=self.target_affordance, + rationale=self.rationale, + expected_effect=self.expected_effect, + max_risk_level=self.max_risk_level, + evidence_ids=self.evidence_ids, + # Identity comes *from the verdict*, so the derivation is pure. Letting + # ``EvolutionIntent.create`` mint its own id made this property return a + # different intent on every read: an immutable domain object whose derived + # value changed each time it was looked at, which no test would notice + # until two reads were compared. It also makes an intent traceable back to + # the verdict that asked for it. + intent_id=f"wmi-{self.verdict_id.removeprefix('adv-')}", + created_at=self.created_at, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "verdict_id": self.verdict_id, + "action": self.action, + "capability": self.capability, + "knowledge": self.knowledge, + "rationale": self.rationale, + "confidence": self.confidence, + "target": self.target, + "writes_code": self.writes_code, + "max_risk_level": str(self.max_risk_level), + "expected_effect": self.expected_effect, + "target_affordance": self.target_affordance, + } + + +__all__ = [ + "ACQUIRE", + "ADAPTATION_ACTIONS", + "AdaptationAction", + "AdaptationVerdict", +] diff --git a/src/leapflow/domain/capability_requirement.py b/src/leapflow/domain/capability_requirement.py index 1ab605f5..436e26d9 100644 --- a/src/leapflow/domain/capability_requirement.py +++ b/src/leapflow/domain/capability_requirement.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Domain records for adaptive capability requirements. A requirement describes what LeapFlow needs, not which concrete tool should be diff --git a/src/leapflow/domain/effect_scope.py b/src/leapflow/domain/effect_scope.py index a1ecd8f6..1a1ae1a8 100644 --- a/src/leapflow/domain/effect_scope.py +++ b/src/leapflow/domain/effect_scope.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Reversible effect tracking for plugin lifecycle management.""" from __future__ import annotations diff --git a/src/leapflow/domain/environment_fingerprint.py b/src/leapflow/domain/environment_fingerprint.py index b1d9044f..58e1dcd8 100644 --- a/src/leapflow/domain/environment_fingerprint.py +++ b/src/leapflow/domain/environment_fingerprint.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Immutable environment fingerprint used by adaptive capability resolution. The fingerprint is a compact, stable view of structured facts: platform diff --git a/src/leapflow/domain/event_types.py b/src/leapflow/domain/event_types.py index 3768d95d..fc8ec299 100644 --- a/src/leapflow/domain/event_types.py +++ b/src/leapflow/domain/event_types.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Canonical event type constants — single source of truth for all event types. Every module that emits, subscribes, or matches event types MUST import diff --git a/src/leapflow/domain/events.py b/src/leapflow/domain/events.py index 91df2cd1..3b4355f9 100644 --- a/src/leapflow/domain/events.py +++ b/src/leapflow/domain/events.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Core event types shared across all layers.""" from __future__ import annotations diff --git a/src/leapflow/domain/evolution_intent.py b/src/leapflow/domain/evolution_intent.py index a05ccfd7..86aef61d 100644 --- a/src/leapflow/domain/evolution_intent.py +++ b/src/leapflow/domain/evolution_intent.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """The world model's evolution proposal contract. An ``EvolutionIntent`` is what the LLM-based world model emits when, given @@ -29,6 +30,7 @@ from __future__ import annotations +import re import time import uuid from dataclasses import dataclass, field @@ -58,6 +60,23 @@ _RISK_ORDER: tuple[str, ...] = ("read_only", "low", "medium", "high", "mutating", "external") +#: What a capability name looks like. One definition, because two nearly-identical +#: regexes diverged in exactly the way that produces a silent drop: the parser's gate +#: accepted ``chat.2fa`` and the verdict constructor rejected it, so a legitimately +#: named capability was discarded with only a debug log. Bounds are deliberate -- a +#: forty-character segment is prose, and prose must never become a requirement. +_CAPABILITY_NAME = re.compile(r"^[a-z][a-z0-9_]{1,31}(\.[a-z0-9][a-z0-9_]{0,31}){1,3}$") + + +def is_capability_name(value: str) -> bool: + """Whether a model-supplied string is shaped like a capability at all. + + Requires lowercase dotted structure with 2-4 segments. Rejects prose, bare words, + paths, and anything long enough to be a description rather than a name. + """ + return bool(value) and len(value) <= 96 and bool(_CAPABILITY_NAME.match(value)) + + def _risk_rank(level: str) -> int: """Rank a risk level, treating anything unknown as the most permissive. diff --git a/src/leapflow/domain/evolution_trace.py b/src/leapflow/domain/evolution_trace.py index 9043ac60..be70357a 100644 --- a/src/leapflow/domain/evolution_trace.py +++ b/src/leapflow/domain/evolution_trace.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Causal types for framework self-evolution: one atomic fact, and one episode. Two concepts sit beside :mod:`leapflow.domain.evolution_intent`, and the pairing is diff --git a/src/leapflow/domain/perception.py b/src/leapflow/domain/perception.py index 2c12e406..0e083684 100644 --- a/src/leapflow/domain/perception.py +++ b/src/leapflow/domain/perception.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Perceptual Field domain types — fine-grained context-aware perception control. Defines the vocabulary for expressing per-context perception policies: diff --git a/src/leapflow/domain/platform.py b/src/leapflow/domain/platform.py index a61ba618..414f6663 100644 --- a/src/leapflow/domain/platform.py +++ b/src/leapflow/domain/platform.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Platform capability discovery and manifest types.""" from __future__ import annotations diff --git a/src/leapflow/domain/plugin_fiber.py b/src/leapflow/domain/plugin_fiber.py index 101846bf..6b92bad9 100644 --- a/src/leapflow/domain/plugin_fiber.py +++ b/src/leapflow/domain/plugin_fiber.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Plugin lifecycle state machine (PluginFiber). Manages the runtime lifecycle of a single plugin instance through a diff --git a/src/leapflow/domain/plugin_proposal.py b/src/leapflow/domain/plugin_proposal.py index d99a49e2..3f797795 100644 --- a/src/leapflow/domain/plugin_proposal.py +++ b/src/leapflow/domain/plugin_proposal.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Domain types for capability gaps and plugin proposals. These immutable records are the reviewable bridge between observing that diff --git a/src/leapflow/domain/skill_types.py b/src/leapflow/domain/skill_types.py index 29f74d45..01e75e58 100644 --- a/src/leapflow/domain/skill_types.py +++ b/src/leapflow/domain/skill_types.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shared skill-related data types used across learning and runtime layers.""" from __future__ import annotations diff --git a/src/leapflow/domain/tool_pipeline.py b/src/leapflow/domain/tool_pipeline.py index 5d2c3afa..b7a15c53 100644 --- a/src/leapflow/domain/tool_pipeline.py +++ b/src/leapflow/domain/tool_pipeline.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Waterfall tool execution pipeline — composable interceptor chain. Interceptors wrap tool execution with pre/post hooks, enabling pluggable diff --git a/src/leapflow/domain/trajectory.py b/src/leapflow/domain/trajectory.py index 02127151..07b64186 100644 --- a/src/leapflow/domain/trajectory.py +++ b/src/leapflow/domain/trajectory.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Core data model for imitation learning trajectories. Defines the experience hierarchy: diff --git a/src/leapflow/domain/ui_vocabulary.py b/src/leapflow/domain/ui_vocabulary.py index 7193d562..1cea6dc7 100644 --- a/src/leapflow/domain/ui_vocabulary.py +++ b/src/leapflow/domain/ui_vocabulary.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shared UI vocabulary — ActionType ↔ tool name mappings. This module connects the Recording vocabulary (ActionType enum values) diff --git a/src/leapflow/engine/__init__.py b/src/leapflow/engine/__init__.py index 64dc44bb..2ca8a532 100644 --- a/src/leapflow/engine/__init__.py +++ b/src/leapflow/engine/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Engine layer — orchestration, planning, scheduling, and session control.""" from leapflow.engine.engine import AgentEngine, StreamEvent, build_default_registry diff --git a/src/leapflow/engine/agent_loop.py b/src/leapflow/engine/agent_loop.py index 9dffc118..ed1876b5 100644 --- a/src/leapflow/engine/agent_loop.py +++ b/src/leapflow/engine/agent_loop.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Per-frame execution state for the agent OODA loop (W4-M1). An ``AgentLoopFrame`` bundles everything that must be *fresh and isolated* for a diff --git a/src/leapflow/engine/audit.py b/src/leapflow/engine/audit.py index 43b7ba98..0dd03a30 100644 --- a/src/leapflow/engine/audit.py +++ b/src/leapflow/engine/audit.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Structured JSONL audit logger for mode transitions, skill executions, and learning events.""" from __future__ import annotations diff --git a/src/leapflow/engine/budget.py b/src/leapflow/engine/budget.py index 6394a0cf..3a82637d 100644 --- a/src/leapflow/engine/budget.py +++ b/src/leapflow/engine/budget.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Iteration budget management for bounded agent loops.""" from __future__ import annotations diff --git a/src/leapflow/engine/confirmation.py b/src/leapflow/engine/confirmation.py index c607ef5b..26a97e7f 100644 --- a/src/leapflow/engine/confirmation.py +++ b/src/leapflow/engine/confirmation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Human-in-the-loop confirmation for skill execution. Implements the graduation mechanism: skills progress from STEP → CONFIRM → diff --git a/src/leapflow/engine/context_compressor.py b/src/leapflow/engine/context_compressor.py index 02c61596..09372b39 100644 --- a/src/leapflow/engine/context_compressor.py +++ b/src/leapflow/engine/context_compressor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Multi-stage context compression pipeline (Chain of Responsibility). Four stages applied in order, each only activating when token budget is exceeded: diff --git a/src/leapflow/engine/context_control.py b/src/leapflow/engine/context_control.py index 28900033..55b1fb56 100644 --- a/src/leapflow/engine/context_control.py +++ b/src/leapflow/engine/context_control.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Adaptive context governance primitives for every agent interaction. The module keeps context accounting, overflow prevention, exploration-ledger diff --git a/src/leapflow/engine/context_disclosure.py b/src/leapflow/engine/context_disclosure.py index df89c15e..98bffeaf 100644 --- a/src/leapflow/engine/context_disclosure.py +++ b/src/leapflow/engine/context_disclosure.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Progressive context disclosure for unified agent turns. This module decides how much runtime context a turn should disclose before the diff --git a/src/leapflow/engine/context_focus.py b/src/leapflow/engine/context_focus.py index 9f443111..43f3aba5 100644 --- a/src/leapflow/engine/context_focus.py +++ b/src/leapflow/engine/context_focus.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Session-level semantic focus state for prompt context assembly. The focus plane is deliberately separate from progressive tool disclosure. PCD diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index c265d66b..5fbe8663 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Main ReAct-style engine with routing, skills, and audit logging.""" from __future__ import annotations @@ -11,7 +12,7 @@ from dataclasses import asdict, dataclass, replace from datetime import datetime from pathlib import Path -from typing import Any, AsyncIterator, Dict, List, Literal, Optional, Union +from typing import Any, AsyncIterator, ClassVar, Dict, List, Literal, Optional, Union from leapflow.platform.protocol import HostRpc, Methods from leapflow.config import Settings @@ -1310,6 +1311,20 @@ def __init__( self._focus_state = SessionFocusState() self._reference_resolver = ReferenceResolver() self._last_reference_resolution: ReferenceResolution | None = None + # Distilled knowledge is read on the hot path and written on the cold one, so + # the engine holds the reader. Bound lazily rather than in the constructor + # because the profile layout is not always present (tests, in-process CLI), and + # a missing store must degrade context quality rather than fail construction. + self._knowledge_store: Any = None + # Set once a lookup has failed, so a persistent failure costs one attempt rather + # than one per turn. The cold-path governor deliberately keeps retrying -- a + # sweep runs once per session, so a transient error there should not disable + # governance for the life of the process. + self._knowledge_store_unavailable: bool = False + # The environment the current session runs in. Compared against the environment + # a fact was learned in, so a stale-looking fact can be disclosed *as* such + # instead of being silently dropped or silently trusted. + self._environment_fingerprint_id: str = "" # Tier 1 structural continuity gate: capability categories used by native # tool_calls in the most recently completed turn. Working memory only # stores a synthetic "[Called: ...]" summary (no structured tool_calls), @@ -2084,6 +2099,123 @@ def _semantic_focus_context(self, user_text: str) -> str: ) return self._focus_state.render_prompt_context(visible_resolution) + #: How a verdict's ``target`` reads to the student, per action. ``""`` is the + #: fallback, so an action added to the domain without a phrase here still discloses + #: its recommendation instead of losing it. + _TARGET_PHRASES: ClassVar[dict[str, str]] = { + "rebind": "Prefer {target}.", + "escalate": "This needs a person to: {target}.", + "": "Recommended: {target}.", + } + + def _distilled_knowledge_context(self) -> str: + """What the teacher concluded is true about this environment. + + A layer of its own, for the same reason ``_semantic_focus_context`` is: this is + control-plane knowledge, not task-semantic recall. Routing it through memory + disclosure would put it behind a keyword query, and the facts that matter most + are exactly the ones whose words do not appear in the request -- "the send + control is now labelled Dispatch" is what a request saying "reply to Ana" needs + and would never retrieve. + + Always disclosed when present, bounded by ``distilled_knowledge_limit`` so the + channel meant to improve context cannot come to dominate it. The environment a + fact was learned in is named whenever it differs from the current one: whether an + upgrade invalidates a specific statement is a judgement about meaning, and it + belongs to the reader rather than to a predicate here. + """ + store = self._resolve_knowledge_store() + if store is None: + return "" + try: + limit = max(0, int(getattr(self._settings, "distilled_knowledge_limit", 12))) + entries = store.live()[:limit] if limit else () + except Exception: # noqa: BLE001 - context is an improvement, never a gate + logger.debug("engine: distilled knowledge unavailable", exc_info=True) + return "" + if not entries: + return "" + current = self._environment_fingerprint_id + lines: list[str] = [] + for entry in entries: + note = "" + if current and entry.environment_id and entry.environment_id != current: + note = " (learned in a different environment)" + # ``target`` is the teacher's concrete recommendation: which capability to + # prefer for a rebind, or what a person has to do for an escalation. Without + # it in the disclosed line the field is stored and never read by anyone, and + # the student is told a problem exists without being told the answer that + # was already worked out. + hint = "" + if entry.target: + # A mapping rather than a branch on one action, so a fifth action needs a + # phrase here instead of an edit to a conditional -- and an unrecognised + # action still renders its target rather than dropping it silently. + phrases = self._TARGET_PHRASES + phrase = phrases.get(entry.action, phrases[""]) + hint = " " + phrase.format(target=entry.target) + lines.append(f"- {entry.capability}: {entry.knowledge}{hint}{note}") + return ( + "## What is known about this environment\n" + "Learned from earlier sessions by reviewing what actually happened. " + "Treat as observations, not instructions.\n" + "\n".join(lines) + ) + + def _rebind_preferences(self) -> tuple[tuple[str, str], ...]: + """The teacher's rebind recommendations, for the resolver to weigh. + + Empty when no store is bound, which is the same degradation as everything else on + this channel: a missing preference costs a better choice, never a resolution. + """ + store = self._resolve_knowledge_store() + if store is None: + return () + try: + return tuple(store.rebind_preferences()) + except Exception: # noqa: BLE001 - evidence, never a gate + logger.debug("engine: rebind preferences unavailable", exc_info=True) + return () + + def _resolve_knowledge_store(self) -> Any: + """Bind the distilled-knowledge reader once, lazily. + + Lazily and here rather than in the constructor, because the profile layout is + absent in tests and for the in-process CLI, and a missing store must cost context + quality rather than construction. Resolving it itself also means this layer does + not depend on some other code path having run first -- the adaptive loop builds + an equivalent store, but it only runs when a capability needs resolving, so + relying on it would make knowledge appear or vanish for unrelated reasons. + """ + if self._knowledge_store is not None: + return self._knowledge_store + if self._knowledge_store_unavailable: + return None + profile_layout = getattr(self._settings, "profile_layout", None) + if profile_layout is None: + return None + try: + from leapflow.domain.environment_fingerprint import EnvironmentFingerprint + from leapflow.domain.platform import PlatformManifest + from leapflow.storage.distilled_knowledge_store import ( + JsonDistilledKnowledgeStore, + ) + + self._knowledge_store = JsonDistilledKnowledgeStore( + profile_layout.distilled_knowledge_path, + ttl_seconds=float( + getattr(self._settings, "distilled_knowledge_ttl_s", 0.0) or 0.0 + ), + ) + self._environment_fingerprint_id = EnvironmentFingerprint.from_platform_manifest( + PlatformManifest.default_darwin(), + workspace_root=getattr(self._settings, "workspace_root", ""), + ).fingerprint_id + except Exception: # noqa: BLE001 - context is an improvement, never a gate + logger.debug("engine: distilled knowledge store unavailable", exc_info=True) + self._knowledge_store = None + self._knowledge_store_unavailable = True + return self._knowledge_store + def _focus_turn_id(self) -> int: """Return a stable monotonic turn id for focus observations.""" try: @@ -2212,7 +2344,10 @@ async def _assemble_unified_prompt( skill_section = self._build_skill_section(include_skills=plan.level != DisclosureLevel.CORE) app_connector_section = self._build_app_connector_section() focus_context = self._semantic_focus_context(user_text) - memory_context = "\n\n".join(part for part in (focus_context, memory_context) if part) + knowledge_context = self._distilled_knowledge_context() + memory_context = "\n\n".join( + part for part in (knowledge_context, focus_context, memory_context) if part + ) system = UNIFIED_SYSTEM_TEMPLATE.format( tool_catalog=tool_catalog, app_connector_section=app_connector_section, @@ -5463,7 +5598,11 @@ def _observe_capability_result(self, result: Any) -> None: CapabilityObservationService, ) from leapflow.plugins import get_registry - from leapflow.plugins.adaptive_loop import AdaptiveLoopRequest, AdaptivePluginLoop + from leapflow.plugins.adaptive_loop import ( + AdaptiveLoopRequest, + AdaptivePluginLoop, + live_learning_signals, + ) from leapflow.storage.capability_observation_store import JsonCapabilityObservationStore from leapflow.storage.capability_plan_store import JsonCapabilityPlanStore @@ -5499,7 +5638,25 @@ def _observe_capability_result(self, result: Any) -> None: len(buffer.observations()), ) store = JsonCapabilityPlanStore(profile_layout.capability_plans_path) - loop = AdaptivePluginLoop(registry=registry, plan_store=store) + trust_ledger, usage_tracker = live_learning_signals() + loop = AdaptivePluginLoop( + registry=registry, + plan_store=store, + # Without these two, ``TrustScorer`` and ``ReliabilityScorer`` report + # "unavailable" and score 0 for every candidate, so the two adaptive + # signals contribute nothing and an alphabetical tie-break decides. + trust_ledger=trust_ledger, + usage_tracker=usage_tracker, + # The live settings, not ``get_settings()``: that singleton is a boot + # snapshot with no refresh path, while ``_settings`` is what + # ``reconfigure_runtime`` replaces. Pushing it is what makes + # ``selection.policy`` genuinely hot-reloadable. + settings=self._settings, + # Channel C2: the teacher's rebind recommendation becomes a *preference* + # in scoring. Resolved through the engine's own store so it follows the + # same expiry and retraction as the knowledge it came from. + distilled_preferences=self._rebind_preferences, + ) decision = loop.resolve_once( AdaptiveLoopRequest( environment=environment, diff --git a/src/leapflow/engine/error_classifier.py b/src/leapflow/engine/error_classifier.py index 512bbde4..ec279234 100644 --- a/src/leapflow/engine/error_classifier.py +++ b/src/leapflow/engine/error_classifier.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Error classification and recovery strategy for agent loops. Enhanced taxonomy inspired by hermes-agent/error_classifier.py: diff --git a/src/leapflow/engine/execution_trace.py b/src/leapflow/engine/execution_trace.py index c4046c14..604db7d2 100644 --- a/src/leapflow/engine/execution_trace.py +++ b/src/leapflow/engine/execution_trace.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Execution state machine and trace recording for the agent loop.""" from __future__ import annotations import time diff --git a/src/leapflow/engine/failure_envelope.py b/src/leapflow/engine/failure_envelope.py index cba57f0e..d5f2a374 100644 --- a/src/leapflow/engine/failure_envelope.py +++ b/src/leapflow/engine/failure_envelope.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Structured failure representation for the recovery subsystem. FailureEnvelope wraps every failure encountered in the agent loop with diff --git a/src/leapflow/engine/graph_planner.py b/src/leapflow/engine/graph_planner.py index cb1d76dc..8b40c737 100644 --- a/src/leapflow/engine/graph_planner.py +++ b/src/leapflow/engine/graph_planner.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LLM-driven Task DAG planner. Generates structured task graphs from natural language goals, diff --git a/src/leapflow/engine/intent_classifier.py b/src/leapflow/engine/intent_classifier.py index 689f9433..f6935c75 100644 --- a/src/leapflow/engine/intent_classifier.py +++ b/src/leapflow/engine/intent_classifier.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LLM-based intent classification module. Design: diff --git a/src/leapflow/engine/interaction_request.py b/src/leapflow/engine/interaction_request.py index 40f9dca8..7c6cbaa9 100644 --- a/src/leapflow/engine/interaction_request.py +++ b/src/leapflow/engine/interaction_request.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Interaction request types for the agent loop recovery subsystem. When automated recovery is insufficient (e.g., permissions, credentials, diff --git a/src/leapflow/engine/message_healer.py b/src/leapflow/engine/message_healer.py index 8a59524c..06468096 100644 --- a/src/leapflow/engine/message_healer.py +++ b/src/leapflow/engine/message_healer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Message sequence healing — fix invalid patterns before LLM call. Repairs (inspired by hermes message_sanitization.py): diff --git a/src/leapflow/engine/message_sanitizer.py b/src/leapflow/engine/message_sanitizer.py index 7f75abc7..6d32d9e3 100644 --- a/src/leapflow/engine/message_sanitizer.py +++ b/src/leapflow/engine/message_sanitizer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Message sanitizer — cleans LLM output of invalid characters and encoding issues. Handles: diff --git a/src/leapflow/engine/oneshot_guard.py b/src/leapflow/engine/oneshot_guard.py index aeea3d34..e611c136 100644 --- a/src/leapflow/engine/oneshot_guard.py +++ b/src/leapflow/engine/oneshot_guard.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """One-shot guard — ensures each strategy key fires at most once per lifetime. Replaces the pattern of 9+ boolean flags (tried_compress, tried_failover, etc.) diff --git a/src/leapflow/engine/pipeline_observer.py b/src/leapflow/engine/pipeline_observer.py index a7d97c3a..4bfb4311 100644 --- a/src/leapflow/engine/pipeline_observer.py +++ b/src/leapflow/engine/pipeline_observer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Pipeline observer protocol — provides observability for multi-phase learning pipelines. Ensures that learning pipeline failures are visible rather than silently swallowed. diff --git a/src/leapflow/engine/planner.py b/src/leapflow/engine/planner.py index 07ae5bb8..24d315c5 100644 --- a/src/leapflow/engine/planner.py +++ b/src/leapflow/engine/planner.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Task planning utilities. Contains: diff --git a/src/leapflow/engine/prefix_commitment.py b/src/leapflow/engine/prefix_commitment.py index 6e5e8db2..87e894b7 100644 --- a/src/leapflow/engine/prefix_commitment.py +++ b/src/leapflow/engine/prefix_commitment.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Adaptive prefix-commitment decision (mechanism 7, W2 slice 2). Decides whether a task should *commit* to a stable, cacheable prompt prefix. diff --git a/src/leapflow/engine/prompt_cache.py b/src/leapflow/engine/prompt_cache.py index 4ac1a274..bfda7571 100644 --- a/src/leapflow/engine/prompt_cache.py +++ b/src/leapflow/engine/prompt_cache.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Prompt cache optimization — reorganizes messages to maximize prefix cache hits. Modern LLM APIs cache request prefixes automatically. This module ensures diff --git a/src/leapflow/engine/recovery_audit.py b/src/leapflow/engine/recovery_audit.py index 4f4a1c7b..6c98e7b1 100644 --- a/src/leapflow/engine/recovery_audit.py +++ b/src/leapflow/engine/recovery_audit.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Recovery audit system — structured logging of all recovery decisions. Every RecoveryCoordinator.evaluate() call produces an audit entry written diff --git a/src/leapflow/engine/recovery_budget.py b/src/leapflow/engine/recovery_budget.py index 845429ad..b476ea1d 100644 --- a/src/leapflow/engine/recovery_budget.py +++ b/src/leapflow/engine/recovery_budget.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Recovery budget — global constraint system for recovery attempts within a turn. The budget prevents infinite recovery loops by enforcing hard caps on retries, diff --git a/src/leapflow/engine/recovery_checkpoint.py b/src/leapflow/engine/recovery_checkpoint.py index 1a0dac0d..f9ca99af 100644 --- a/src/leapflow/engine/recovery_checkpoint.py +++ b/src/leapflow/engine/recovery_checkpoint.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Recovery checkpoint system for cross-turn state persistence and safe resumption. Enables the agent loop to save execution state when halting with HALT_WITH_CHECKPOINT, diff --git a/src/leapflow/engine/recovery_coordinator.py b/src/leapflow/engine/recovery_coordinator.py index c5d24224..d9222c6e 100644 --- a/src/leapflow/engine/recovery_coordinator.py +++ b/src/leapflow/engine/recovery_coordinator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Recovery coordinator — unified recovery decision entry point for the agent loop. Replaces the scattered if/elif chains in _handle_api_error() and the inline diff --git a/src/leapflow/engine/recovery_decision.py b/src/leapflow/engine/recovery_decision.py index e6114aed..757593f6 100644 --- a/src/leapflow/engine/recovery_decision.py +++ b/src/leapflow/engine/recovery_decision.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Recovery decision types for the agent loop recovery subsystem. A RecoveryDecision encapsulates what the coordinator decided to do about a diff --git a/src/leapflow/engine/recovery_strategies/__init__.py b/src/leapflow/engine/recovery_strategies/__init__.py index 8ddee8e4..40615303 100644 --- a/src/leapflow/engine/recovery_strategies/__init__.py +++ b/src/leapflow/engine/recovery_strategies/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Built-in recovery strategies for the agent loop recovery coordinator. Each strategy implements the RecoveryStrategy Protocol and encapsulates diff --git a/src/leapflow/engine/recovery_strategies/context_compress.py b/src/leapflow/engine/recovery_strategies/context_compress.py index ca735583..55c962cc 100644 --- a/src/leapflow/engine/recovery_strategies/context_compress.py +++ b/src/leapflow/engine/recovery_strategies/context_compress.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Context compression recovery strategy. Handles context overflow errors by progressively compressing the conversation diff --git a/src/leapflow/engine/recovery_strategies/credential_rotate.py b/src/leapflow/engine/recovery_strategies/credential_rotate.py index 3b19877d..573911e0 100644 --- a/src/leapflow/engine/recovery_strategies/credential_rotate.py +++ b/src/leapflow/engine/recovery_strategies/credential_rotate.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Credential rotation recovery strategy. Handles auth errors and rate limiting by rotating to alternate credentials diff --git a/src/leapflow/engine/recovery_strategies/jittered_retry.py b/src/leapflow/engine/recovery_strategies/jittered_retry.py index 6187381f..4473f937 100644 --- a/src/leapflow/engine/recovery_strategies/jittered_retry.py +++ b/src/leapflow/engine/recovery_strategies/jittered_retry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Jittered retry recovery strategy. The lowest-priority catch-all retry strategy for transient failures. diff --git a/src/leapflow/engine/recovery_strategies/multimodal_strip.py b/src/leapflow/engine/recovery_strategies/multimodal_strip.py index 9c4d8195..b2735e70 100644 --- a/src/leapflow/engine/recovery_strategies/multimodal_strip.py +++ b/src/leapflow/engine/recovery_strategies/multimodal_strip.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Multimodal strip recovery strategy. Handles image-too-large errors by stripping multimodal content and converting diff --git a/src/leapflow/engine/recovery_strategies/native_to_text.py b/src/leapflow/engine/recovery_strategies/native_to_text.py index 9ae2368a..c937ffa9 100644 --- a/src/leapflow/engine/recovery_strategies/native_to_text.py +++ b/src/leapflow/engine/recovery_strategies/native_to_text.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Native-to-text fallback recovery strategy. Handles format errors by falling back from native tool calling mode to diff --git a/src/leapflow/engine/recovery_strategies/provider_failover.py b/src/leapflow/engine/recovery_strategies/provider_failover.py index e39ae8aa..28372b7d 100644 --- a/src/leapflow/engine/recovery_strategies/provider_failover.py +++ b/src/leapflow/engine/recovery_strategies/provider_failover.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Provider failover recovery strategy. Handles permanent provider failures (billing, auth permanent, overloaded, diff --git a/src/leapflow/engine/recovery_strategies/thinking_disable.py b/src/leapflow/engine/recovery_strategies/thinking_disable.py index 8aeec937..078561c6 100644 --- a/src/leapflow/engine/recovery_strategies/thinking_disable.py +++ b/src/leapflow/engine/recovery_strategies/thinking_disable.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Thinking mode disable recovery strategy. Handles format errors by disabling the LLM's thinking/reasoning mode diff --git a/src/leapflow/engine/recovery_strategies/tool_schema_expand.py b/src/leapflow/engine/recovery_strategies/tool_schema_expand.py index ff7ed7d4..960ae5aa 100644 --- a/src/leapflow/engine/recovery_strategies/tool_schema_expand.py +++ b/src/leapflow/engine/recovery_strategies/tool_schema_expand.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tool schema expansion recovery strategy. Handles unknown tool errors by expanding the tool schema to include diff --git a/src/leapflow/engine/reference_resolver.py b/src/leapflow/engine/reference_resolver.py index 2c4af149..6bcec3f2 100644 --- a/src/leapflow/engine/reference_resolver.py +++ b/src/leapflow/engine/reference_resolver.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Focus-state-driven reference resolution for session context assembly. This resolver does not parse user text for keywords, does not choose tools, diff --git a/src/leapflow/engine/research_ledger.py b/src/leapflow/engine/research_ledger.py index af50367e..f3bc353d 100644 --- a/src/leapflow/engine/research_ledger.py +++ b/src/leapflow/engine/research_ledger.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Structured research ledger for long-horizon task state (mechanism 5, W3). A compact, bounded record of the active task's accumulated findings, open diff --git a/src/leapflow/engine/resilience.py b/src/leapflow/engine/resilience.py index a91d5ba2..44e834c5 100644 --- a/src/leapflow/engine/resilience.py +++ b/src/leapflow/engine/resilience.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Backward-compatible re-export — canonical location is leapflow.utils.resilience.""" from leapflow.utils.resilience import ResiliencePolicy, execute_with_resilience diff --git a/src/leapflow/engine/scheduler.py b/src/leapflow/engine/scheduler.py index 8fb992fd..65829d88 100644 --- a/src/leapflow/engine/scheduler.py +++ b/src/leapflow/engine/scheduler.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DAG-based task scheduler with parallel execution, retry, and fault tolerance. Executes a TaskGraph by: diff --git a/src/leapflow/engine/session.py b/src/leapflow/engine/session.py index 567cbbd2..678aa776 100644 --- a/src/leapflow/engine/session.py +++ b/src/leapflow/engine/session.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Session controller — orchestrates LEARN → DISTILL → EXECUTE lifecycle. Manages the SessionMode state machine and coordinates between the imitation diff --git a/src/leapflow/engine/session_factory.py b/src/leapflow/engine/session_factory.py index 2fa4bc29..e6cedb08 100644 --- a/src/leapflow/engine/session_factory.py +++ b/src/leapflow/engine/session_factory.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Session-scoped engine factory for concurrent, isolated turn execution (Stage 3). Builds a per-session ``AgentEngine`` that SHARES the base engine's stateless / diff --git a/src/leapflow/engine/situational_assessor.py b/src/leapflow/engine/situational_assessor.py index de8a6a1b..8951565b 100644 --- a/src/leapflow/engine/situational_assessor.py +++ b/src/leapflow/engine/situational_assessor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Pre-execution situational assessment for skill execution. Evaluates whether the current environment satisfies a skill's execution diff --git a/src/leapflow/engine/stale_stream.py b/src/leapflow/engine/stale_stream.py index fb28258a..489d1452 100644 --- a/src/leapflow/engine/stale_stream.py +++ b/src/leapflow/engine/stale_stream.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Stale stream detection and partial recovery for LLM streaming. Wraps an async stream iterator with an idle timeout so that hung connections diff --git a/src/leapflow/engine/subagent.py b/src/leapflow/engine/subagent.py index 4cec71b4..6edc7517 100644 --- a/src/leapflow/engine/subagent.py +++ b/src/leapflow/engine/subagent.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Subagent isolation — delegated task execution with restricted context. Design (inspired by hermes delegate_tool): diff --git a/src/leapflow/engine/task_graph.py b/src/leapflow/engine/task_graph.py index c3be80b1..0a38d681 100644 --- a/src/leapflow/engine/task_graph.py +++ b/src/leapflow/engine/task_graph.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Task DAG (Directed Acyclic Graph) data model for complex task orchestration. Supports: diff --git a/src/leapflow/engine/terminal_io.py b/src/leapflow/engine/terminal_io.py index 6fe954b5..ccd7ecad 100644 --- a/src/leapflow/engine/terminal_io.py +++ b/src/leapflow/engine/terminal_io.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Backward-compatible re-export — canonical location is leapflow.utils.terminal_io.""" from leapflow.utils.terminal_io import TerminalIOProvider diff --git a/src/leapflow/engine/tool_concurrency.py b/src/leapflow/engine/tool_concurrency.py index 057b3100..f5f7680b 100644 --- a/src/leapflow/engine/tool_concurrency.py +++ b/src/leapflow/engine/tool_concurrency.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tool concurrency policy — metadata-driven parallel/sequential partitioning. Parallel-safety is derived from the SAME registry metadata that already drives diff --git a/src/leapflow/engine/tool_execution.py b/src/leapflow/engine/tool_execution.py index 49d05c05..193e6de0 100644 --- a/src/leapflow/engine/tool_execution.py +++ b/src/leapflow/engine/tool_execution.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tool execution identity, policy, and idempotency ledger.""" from __future__ import annotations diff --git a/src/leapflow/engine/tool_guardrails.py b/src/leapflow/engine/tool_guardrails.py index 651fa09f..041ede2e 100644 --- a/src/leapflow/engine/tool_guardrails.py +++ b/src/leapflow/engine/tool_guardrails.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tool loop guardrails — detect and halt repeated failures, stagnation, and loops. Monitors tool execution patterns during the agent loop and emits warnings diff --git a/src/leapflow/engine/turn_recovery.py b/src/leapflow/engine/turn_recovery.py index a0becb73..4e774c77 100644 --- a/src/leapflow/engine/turn_recovery.py +++ b/src/leapflow/engine/turn_recovery.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Per-turn recovery state — one-shot guards preventing infinite recovery loops. Each recovery strategy can fire at most once per turn. Prevents: diff --git a/src/leapflow/engine/turn_usage.py b/src/leapflow/engine/turn_usage.py index 0456909c..c707f453 100644 --- a/src/leapflow/engine/turn_usage.py +++ b/src/leapflow/engine/turn_usage.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Per-turn usage tracking and cost estimation. Accumulates token usage, latency, and tool call metrics across a single diff --git a/src/leapflow/engine/unified_classifier.py b/src/leapflow/engine/unified_classifier.py index 6d72a27e..0397e7bd 100644 --- a/src/leapflow/engine/unified_classifier.py +++ b/src/leapflow/engine/unified_classifier.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unified error classifier bridging LLM, tool, and system failures into FailureEnvelope. Provides a single classification entry point that produces FailureEnvelope instances diff --git a/src/leapflow/evolution/__init__.py b/src/leapflow/evolution/__init__.py index 64cb4db7..8a3ad666 100644 --- a/src/leapflow/evolution/__init__.py +++ b/src/leapflow/evolution/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Evolution ledger: the causal view of how the framework changed itself. Two halves that meet at :class:`~leapflow.domain.evolution_trace.EvolutionEpisode`: diff --git a/src/leapflow/evolution/ledger.py b/src/leapflow/evolution/ledger.py index 596f73f4..5bef050c 100644 --- a/src/leapflow/evolution/ledger.py +++ b/src/leapflow/evolution/ledger.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Rebuild evolution episodes from the records the system already keeps. No probe is needed for this. The adaptive loop already persists one decision diff --git a/src/leapflow/evolution/observations.py b/src/leapflow/evolution/observations.py index 30dc9339..f5051b92 100644 --- a/src/leapflow/evolution/observations.py +++ b/src/leapflow/evolution/observations.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """What the co-evolution sweep needs to see, collected where it is produced. The cold-path sweep verifies effects, drains quarantine candidates and scans for diff --git a/src/leapflow/evolution/sink.py b/src/leapflow/evolution/sink.py index 78620fc3..196a5f03 100644 --- a/src/leapflow/evolution/sink.py +++ b/src/leapflow/evolution/sink.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LedgerEvolutionSink: accept traces on the hot side, persist on the cold side. The probe's contract is "accept and return", so ``record`` only appends to a bounded diff --git a/src/leapflow/evolution/sweep.py b/src/leapflow/evolution/sweep.py index 3dadcf55..1f58ea36 100644 --- a/src/leapflow/evolution/sweep.py +++ b/src/leapflow/evolution/sweep.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """The cold-path co-evolution sweep: verify, govern, reclaim. Three capabilities existed in the tree with no caller, which the evolution @@ -146,12 +147,55 @@ async def _verify( summary=f"{verdict.capability}: {verdict.reason}", detail=verdict.to_dict(), ) + # The feedback edge. Every verdict reaches the selection policy, + # including the abstaining ones -- a policy has to see the abstention to + # leave its posterior alone, and hiding it here would make "no + # information" indistinguishable from "never selected". + self._report_to_policy(requirement, verdict) # Only a decided verdict may move trust; "unverifiable" must not # quarantine a plugin for a missing declaration. if verdict.should_record_outcome: await self._record(verdict) return tuple(verdicts) + @staticmethod + def _report_to_policy(requirement: CapabilityRequirement, verdict: EffectVerdict) -> None: + """Tell the active selection policy what came of its choice. + + The edge that was missing: verdicts fed governance (trust, quarantine) but + never the component that made the selection, so no policy could ever learn + from its own decisions. Wired for every policy, with the shipped ``greedy`` + ignoring it, so adding a learning policy is a new file and a config value + rather than a change here. + + The reward is three-valued and carries its source: an execution result and a + verified effect answer different questions, and a policy may weight them + differently. ``value is None`` means abstain, never failure. + """ + try: + from leapflow.plugins.selection_policy import RewardSignal + from leapflow.plugins.selection_policy_registry import ( + get_selection_policy_registry, + ) + + # ``current``, never ``activate``: this is a reporter. Creating the policy + # here would cache one with no host dependencies and beat the component + # that actually selects to the slot. No active policy means nothing has + # selected yet, so there is no decision to report on. + policy = get_selection_policy_registry().current() + if policy is None: + return + policy.observe( + requirement, + verdict.plugin_id, + RewardSignal( + value=None if verdict.verified is None else float(bool(verdict.verified)), + source=verdict.reason, + ), + ) + except Exception: # noqa: BLE001 - learning must not break the sweep + logger.debug("sweep: selection policy did not accept a verdict", exc_info=True) + async def _record(self, verdict: EffectVerdict) -> None: """Feed a decided verdict into trust/lifecycle governance.""" if self.governor is None: diff --git a/src/leapflow/gateway/__init__.py b/src/leapflow/gateway/__init__.py index 80321868..cd3f6b6f 100644 --- a/src/leapflow/gateway/__init__.py +++ b/src/leapflow/gateway/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Gateway module — platform adapter management and message routing. Public API: diff --git a/src/leapflow/gateway/action_packs/__init__.py b/src/leapflow/gateway/action_packs/__init__.py index 21c1998c..e84eeaea 100644 --- a/src/leapflow/gateway/action_packs/__init__.py +++ b/src/leapflow/gateway/action_packs/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Built-in platform action packs.""" diff --git a/src/leapflow/gateway/action_packs/feishu.py b/src/leapflow/gateway/action_packs/feishu.py index a57dd2d3..fe669ef9 100644 --- a/src/leapflow/gateway/action_packs/feishu.py +++ b/src/leapflow/gateway/action_packs/feishu.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Feishu/Lark action pack — loaded from feishu.yaml. All action definitions live in feishu.yaml next to this file. diff --git a/src/leapflow/gateway/adapter_registry.py b/src/leapflow/gateway/adapter_registry.py index 28072aed..4e6bde70 100644 --- a/src/leapflow/gateway/adapter_registry.py +++ b/src/leapflow/gateway/adapter_registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Gateway Adapter Plugin Registry. Provides discovery, registration, and lifecycle management for platform adapters. diff --git a/src/leapflow/gateway/adapters/__init__.py b/src/leapflow/gateway/adapters/__init__.py index 74488032..00cf9b5a 100644 --- a/src/leapflow/gateway/adapters/__init__.py +++ b/src/leapflow/gateway/adapters/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Built-in gateway platform adapters.""" from leapflow.gateway.adapters.api_server import APIServerAdapter diff --git a/src/leapflow/gateway/adapters/api_server.py b/src/leapflow/gateway/adapters/api_server.py index 0739876a..b29b937f 100644 --- a/src/leapflow/gateway/adapters/api_server.py +++ b/src/leapflow/gateway/adapters/api_server.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """OpenAI-compatible API server gateway adapter.""" from __future__ import annotations diff --git a/src/leapflow/gateway/adapters/common.py b/src/leapflow/gateway/adapters/common.py index 2f91f789..881e1595 100644 --- a/src/leapflow/gateway/adapters/common.py +++ b/src/leapflow/gateway/adapters/common.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Common helpers for built-in gateway adapters. The helpers here intentionally stay small: they provide shared lifecycle, diff --git a/src/leapflow/gateway/adapters/dingtalk.py b/src/leapflow/gateway/adapters/dingtalk.py index e5f3d38f..97366fa6 100644 --- a/src/leapflow/gateway/adapters/dingtalk.py +++ b/src/leapflow/gateway/adapters/dingtalk.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DingTalk gateway adapter.""" from __future__ import annotations diff --git a/src/leapflow/gateway/adapters/feishu.py b/src/leapflow/gateway/adapters/feishu.py index 0de6145d..44878164 100644 --- a/src/leapflow/gateway/adapters/feishu.py +++ b/src/leapflow/gateway/adapters/feishu.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Feishu/Lark adapter backed by the official lark-cli.""" from __future__ import annotations diff --git a/src/leapflow/gateway/adapters/telegram.py b/src/leapflow/gateway/adapters/telegram.py index 88524185..35f7d71f 100644 --- a/src/leapflow/gateway/adapters/telegram.py +++ b/src/leapflow/gateway/adapters/telegram.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Telegram Bot gateway adapter.""" from __future__ import annotations diff --git a/src/leapflow/gateway/adapters/webhook.py b/src/leapflow/gateway/adapters/webhook.py index 8195101f..083cc6e1 100644 --- a/src/leapflow/gateway/adapters/webhook.py +++ b/src/leapflow/gateway/adapters/webhook.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Generic HTTP webhook gateway adapter.""" from __future__ import annotations diff --git a/src/leapflow/gateway/backends/__init__.py b/src/leapflow/gateway/backends/__init__.py index 3cd2385d..6ecef212 100644 --- a/src/leapflow/gateway/backends/__init__.py +++ b/src/leapflow/gateway/backends/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Backend package exports.""" from leapflow.gateway.backends.cli_backend import CliBackend from leapflow.gateway.backends.rest_backend import RestBackend diff --git a/src/leapflow/gateway/backends/cli_backend.py b/src/leapflow/gateway/backends/cli_backend.py index 6182ab87..44b3d7d5 100644 --- a/src/leapflow/gateway/backends/cli_backend.py +++ b/src/leapflow/gateway/backends/cli_backend.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """CLI execution backend for App Connector actions.""" from __future__ import annotations diff --git a/src/leapflow/gateway/backends/lark_cli_errors.py b/src/leapflow/gateway/backends/lark_cli_errors.py index 7d18d7f7..df65bffc 100644 --- a/src/leapflow/gateway/backends/lark_cli_errors.py +++ b/src/leapflow/gateway/backends/lark_cli_errors.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """lark-cli error normalization for App Connector CLI actions. This module translates lark-cli's Problem JSON and legacy plain-text failures diff --git a/src/leapflow/gateway/backends/rest_backend.py b/src/leapflow/gateway/backends/rest_backend.py index 28671b91..6f2b39e0 100644 --- a/src/leapflow/gateway/backends/rest_backend.py +++ b/src/leapflow/gateway/backends/rest_backend.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """REST execution backend for App Connector actions.""" from __future__ import annotations diff --git a/src/leapflow/gateway/capability_health.py b/src/leapflow/gateway/capability_health.py index edd4849b..b9ee2b77 100644 --- a/src/leapflow/gateway/capability_health.py +++ b/src/leapflow/gateway/capability_health.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Capability health tracking for platform actions. Maintains a session-scoped ledger that records authorization failures for diff --git a/src/leapflow/gateway/checkpoint_store.py b/src/leapflow/gateway/checkpoint_store.py index bbdf1cde..ba4ffe1c 100644 --- a/src/leapflow/gateway/checkpoint_store.py +++ b/src/leapflow/gateway/checkpoint_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Gateway event checkpoint persistence. Stores the last-consumed event_id per platform so event sources can diff --git a/src/leapflow/gateway/config_store.py b/src/leapflow/gateway/config_store.py index 829aa127..c64e30b2 100644 --- a/src/leapflow/gateway/config_store.py +++ b/src/leapflow/gateway/config_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Gateway configuration persistence (``gateway.yaml``). Reads and writes platform configurations. Manifest-declared secret fields are diff --git a/src/leapflow/gateway/connectors/__init__.py b/src/leapflow/gateway/connectors/__init__.py index 1da1a3c2..1ae655e8 100644 --- a/src/leapflow/gateway/connectors/__init__.py +++ b/src/leapflow/gateway/connectors/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Connector package exports.""" from leapflow.gateway.connectors.action_registry import ActionRegistry, summarize_action_result, validate_payload from leapflow.gateway.connectors.cli_discovery import CliDiscovery, DiscoveredCommand, HelpParser, HelpParseResult diff --git a/src/leapflow/gateway/connectors/action_registry.py b/src/leapflow/gateway/connectors/action_registry.py index d847bdb1..e71520b0 100644 --- a/src/leapflow/gateway/connectors/action_registry.py +++ b/src/leapflow/gateway/connectors/action_registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Action registry utilities for App Connector platform actions. Supports three sources in priority order: diff --git a/src/leapflow/gateway/connectors/cli_discovery.py b/src/leapflow/gateway/connectors/cli_discovery.py index 10ec40c6..7081a3bb 100644 --- a/src/leapflow/gateway/connectors/cli_discovery.py +++ b/src/leapflow/gateway/connectors/cli_discovery.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """CLI help-based command discovery for App Connector backends. Discovers available commands and arguments by invoking `` --help`` diff --git a/src/leapflow/gateway/connectors/composite_event_source.py b/src/leapflow/gateway/connectors/composite_event_source.py index 76f59cb0..cb774600 100644 --- a/src/leapflow/gateway/connectors/composite_event_source.py +++ b/src/leapflow/gateway/connectors/composite_event_source.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Composite event source that merges multiple BackendEventSource streams. Subscribes to N child sources and yields events from all of them through diff --git a/src/leapflow/gateway/connectors/dingtalk_event_source.py b/src/leapflow/gateway/connectors/dingtalk_event_source.py index 8ea00c41..a5b51363 100644 --- a/src/leapflow/gateway/connectors/dingtalk_event_source.py +++ b/src/leapflow/gateway/connectors/dingtalk_event_source.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DingTalk webhook event source. Implements ``BackendEventSource`` by running a lightweight HTTP server diff --git a/src/leapflow/gateway/connectors/event_sources.py b/src/leapflow/gateway/connectors/event_sources.py index a34d5239..e972c772 100644 --- a/src/leapflow/gateway/connectors/event_sources.py +++ b/src/leapflow/gateway/connectors/event_sources.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Reusable backend event source implementations.""" from __future__ import annotations diff --git a/src/leapflow/gateway/connectors/lark_event_source.py b/src/leapflow/gateway/connectors/lark_event_source.py index fbf67561..87e654bd 100644 --- a/src/leapflow/gateway/connectors/lark_event_source.py +++ b/src/leapflow/gateway/connectors/lark_event_source.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Feishu/Lark event source backed by ``lark-cli event consume``.""" from __future__ import annotations diff --git a/src/leapflow/gateway/connectors/protocol.py b/src/leapflow/gateway/connectors/protocol.py index ed845139..a2867a8d 100644 --- a/src/leapflow/gateway/connectors/protocol.py +++ b/src/leapflow/gateway/connectors/protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """App connector protocols for platform action execution. This module defines the platform-neutral contract used by REST, CLI, and diff --git a/src/leapflow/gateway/connectors/telegram_event_source.py b/src/leapflow/gateway/connectors/telegram_event_source.py index 5aa58eeb..9fcef3b7 100644 --- a/src/leapflow/gateway/connectors/telegram_event_source.py +++ b/src/leapflow/gateway/connectors/telegram_event_source.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Telegram polling event source. Implements ``BackendEventSource`` by long-polling the Telegram Bot API diff --git a/src/leapflow/gateway/credential_vault.py b/src/leapflow/gateway/credential_vault.py index 1fb85519..0420d4fd 100644 --- a/src/leapflow/gateway/credential_vault.py +++ b/src/leapflow/gateway/credential_vault.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Gateway credential refs backed by the unified LeapFlow secret vault. Gateway config files store only ``secret://`` references for manifest-declared diff --git a/src/leapflow/gateway/event_bridge.py b/src/leapflow/gateway/event_bridge.py index 6298a323..9b5370df 100644 --- a/src/leapflow/gateway/event_bridge.py +++ b/src/leapflow/gateway/event_bridge.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Bridge between gateway events and the platform EventBus. Subscribes to ``GatewayServer.on_event`` and publishes equivalent diff --git a/src/leapflow/gateway/events.py b/src/leapflow/gateway/events.py index 8949097a..01ebfb82 100644 --- a/src/leapflow/gateway/events.py +++ b/src/leapflow/gateway/events.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Gateway event types for EventBus integration. Gateway publishes events; MemoryManager, Copilot, and AgentEngine can diff --git a/src/leapflow/gateway/manifest.py b/src/leapflow/gateway/manifest.py index 8057732b..eb116369 100644 --- a/src/leapflow/gateway/manifest.py +++ b/src/leapflow/gateway/manifest.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Platform manifest loading and discovery. A manifest is a YAML file that declares: diff --git a/src/leapflow/gateway/mixin.py b/src/leapflow/gateway/mixin.py index 5fbfa142..2e963596 100644 --- a/src/leapflow/gateway/mixin.py +++ b/src/leapflow/gateway/mixin.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Default implementations for optional ``PlatformAdapter`` capabilities. Adapters mix this in to get graceful degradation for methods they do not diff --git a/src/leapflow/gateway/normalizers/__init__.py b/src/leapflow/gateway/normalizers/__init__.py index 5e8923d6..7827a240 100644 --- a/src/leapflow/gateway/normalizers/__init__.py +++ b/src/leapflow/gateway/normalizers/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Platform event normalizers.""" diff --git a/src/leapflow/gateway/normalizers/dingtalk.py b/src/leapflow/gateway/normalizers/dingtalk.py index a14f6d53..9c159260 100644 --- a/src/leapflow/gateway/normalizers/dingtalk.py +++ b/src/leapflow/gateway/normalizers/dingtalk.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DingTalk event normalizer. Maps DingTalk webhook/stream callback payloads (from a ``BackendEventSource``) diff --git a/src/leapflow/gateway/normalizers/feishu.py b/src/leapflow/gateway/normalizers/feishu.py index fc5de77c..41d8ada8 100644 --- a/src/leapflow/gateway/normalizers/feishu.py +++ b/src/leapflow/gateway/normalizers/feishu.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Feishu/Lark event normalizer. Maps the flat NDJSON output of ``lark-cli event consume`` into the diff --git a/src/leapflow/gateway/normalizers/telegram.py b/src/leapflow/gateway/normalizers/telegram.py index c0d07659..d5f7af0f 100644 --- a/src/leapflow/gateway/normalizers/telegram.py +++ b/src/leapflow/gateway/normalizers/telegram.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Telegram event normalizer. Maps raw Telegram update payloads (from a ``BackendEventSource``) diff --git a/src/leapflow/gateway/protocol.py b/src/leapflow/gateway/protocol.py index c77a6c38..fe5c5302 100644 --- a/src/leapflow/gateway/protocol.py +++ b/src/leapflow/gateway/protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Gateway protocol types and adapter interface. Defines the contract between platform adapters and the gateway server. diff --git a/src/leapflow/gateway/resource_provenance.py b/src/leapflow/gateway/resource_provenance.py index bd954dc1..d4cceecc 100644 --- a/src/leapflow/gateway/resource_provenance.py +++ b/src/leapflow/gateway/resource_provenance.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Resource provenance tracking for platform actions. Maintains a session-scoped pool of resource identifiers (chat_id, message_id, diff --git a/src/leapflow/gateway/router.py b/src/leapflow/gateway/router.py index ea9f0297..9f74ac55 100644 --- a/src/leapflow/gateway/router.py +++ b/src/leapflow/gateway/router.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Gateway message router — per-session LLM processing for inbound platform messages. Sits between ``GatewayServer`` (message ingress) and the LLM/tool layer diff --git a/src/leapflow/gateway/scoped_adapter_registry.py b/src/leapflow/gateway/scoped_adapter_registry.py index d230c2cd..65657043 100644 --- a/src/leapflow/gateway/scoped_adapter_registry.py +++ b/src/leapflow/gateway/scoped_adapter_registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scoped lifecycle wrapper for GatewayAdapterRegistry. Leverages the existing unregister() method for cleanup, and mirrors the diff --git a/src/leapflow/gateway/server.py b/src/leapflow/gateway/server.py index 6bf9069b..eac02b2f 100644 --- a/src/leapflow/gateway/server.py +++ b/src/leapflow/gateway/server.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Gateway server — manages platform adapters and routes messages. Intentionally thin (< 250 lines): session / transcript persistence, diff --git a/src/leapflow/gateway/session_router.py b/src/leapflow/gateway/session_router.py index 4be45147..1582fc87 100644 --- a/src/leapflow/gateway/session_router.py +++ b/src/leapflow/gateway/session_router.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Structured session routing for gateway messages. ``SessionKey`` is an immutable domain type that replaces simple string diff --git a/src/leapflow/gateway/trigger_policy.py b/src/leapflow/gateway/trigger_policy.py index 820b269b..e385dd06 100644 --- a/src/leapflow/gateway/trigger_policy.py +++ b/src/leapflow/gateway/trigger_policy.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Trigger policy for inbound IM messages. Controls which inbound messages activate the agent's Decide stage. diff --git a/src/leapflow/gateway/validators/__init__.py b/src/leapflow/gateway/validators/__init__.py index cfb298d3..b85695c6 100644 --- a/src/leapflow/gateway/validators/__init__.py +++ b/src/leapflow/gateway/validators/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Platform credential validation: a neutral registry plus per-vendor modules. Each validator is a simple async function: diff --git a/src/leapflow/gateway/validators/_http.py b/src/leapflow/gateway/validators/_http.py index 4c6fb5be..bd0aed6c 100644 --- a/src/leapflow/gateway/validators/_http.py +++ b/src/leapflow/gateway/validators/_http.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shared HTTP helper for credential validators.""" from __future__ import annotations diff --git a/src/leapflow/gateway/validators/dingtalk.py b/src/leapflow/gateway/validators/dingtalk.py index cd467277..30ae4017 100644 --- a/src/leapflow/gateway/validators/dingtalk.py +++ b/src/leapflow/gateway/validators/dingtalk.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DingTalk credential validator. Referenced declaratively by ``manifests/dingtalk.yaml`` as diff --git a/src/leapflow/gateway/validators/telegram.py b/src/leapflow/gateway/validators/telegram.py index 927cad8c..a6b75e31 100644 --- a/src/leapflow/gateway/validators/telegram.py +++ b/src/leapflow/gateway/validators/telegram.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Telegram credential validator. Referenced declaratively by ``manifests/telegram.yaml`` as diff --git a/src/leapflow/hardware/__init__.py b/src/leapflow/hardware/__init__.py index 5b861343..da8b5ec2 100644 --- a/src/leapflow/hardware/__init__.py +++ b/src/leapflow/hardware/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hardware Context Protocol -- safe agent operation of physical devices. The protocol splits device integration along the axis of what can be known: diff --git a/src/leapflow/hardware/alert_policy.py b/src/leapflow/hardware/alert_policy.py index 03ecf325..9daf5fe6 100644 --- a/src/leapflow/hardware/alert_policy.py +++ b/src/leapflow/hardware/alert_policy.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Declarative alert policies: event kind → automated response. A policy is a YAML rule that maps an observed ``EventKind`` to an action. The diff --git a/src/leapflow/hardware/audit.py b/src/leapflow/hardware/audit.py index b90a3107..779ded2b 100644 --- a/src/leapflow/hardware/audit.py +++ b/src/leapflow/hardware/audit.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Structured NDJSON audit log for hardware operations. Every read, write, and emergency-stop that passes through ``HardwareTools`` is diff --git a/src/leapflow/hardware/calibration_store.py b/src/leapflow/hardware/calibration_store.py index 3f3f4369..1f8437bb 100644 --- a/src/leapflow/hardware/calibration_store.py +++ b/src/leapflow/hardware/calibration_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Versioned storage for device calibration results. A calibration is not a reading. A reading is a sample of what a channel is doing right diff --git a/src/leapflow/hardware/context.py b/src/leapflow/hardware/context.py index cafd4118..96b98c9e 100644 --- a/src/leapflow/hardware/context.py +++ b/src/leapflow/hardware/context.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hardware context: the declarative half of the Hardware Context Protocol. This module is deliberately free of any transport, vendor, or upstream-standard diff --git a/src/leapflow/hardware/host_metrics.py b/src/leapflow/hardware/host_metrics.py index dafcad5c..e6473229 100644 --- a/src/leapflow/hardware/host_metrics.py +++ b/src/leapflow/hardware/host_metrics.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Host resource probes: the one table both the host provider and transport read. The machine LeapFlow runs on is a device like any other -- it has quantities, diff --git a/src/leapflow/hardware/media.py b/src/leapflow/hardware/media.py index 94e3f878..fff7840d 100644 --- a/src/leapflow/hardware/media.py +++ b/src/leapflow/hardware/media.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Local media devices: enumeration and frame capture, behind one backend table. The counterpart to ``host_metrics`` for media. Cameras and microphones are ordinary diff --git a/src/leapflow/hardware/observability/__init__.py b/src/leapflow/hardware/observability/__init__.py index 4104120b..394e28dc 100644 --- a/src/leapflow/hardware/observability/__init__.py +++ b/src/leapflow/hardware/observability/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Physical-signal observability: the board's view of the bench. Four files, four reasons to change: ``series`` when the payload shape changes, diff --git a/src/leapflow/hardware/observability/digest.py b/src/leapflow/hardware/observability/digest.py index 2ed7b771..502cfc49 100644 --- a/src/leapflow/hardware/observability/digest.py +++ b/src/leapflow/hardware/observability/digest.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Derive the board payload from what the registry already knows. Pure with respect to the registry: it reads, it never samples, writes, or opens a diff --git a/src/leapflow/hardware/observability/exporter.py b/src/leapflow/hardware/observability/exporter.py index 2432b6eb..c206248a 100644 --- a/src/leapflow/hardware/observability/exporter.py +++ b/src/leapflow/hardware/observability/exporter.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Prometheus-style metrics exporter for hardware observability. Maps ``ReadingStore`` and ``HardwareStreamSource`` counters to named gauge and diff --git a/src/leapflow/hardware/observability/inventory.py b/src/leapflow/hardware/observability/inventory.py index d9c6a6e7..407a8381 100644 --- a/src/leapflow/hardware/observability/inventory.py +++ b/src/leapflow/hardware/observability/inventory.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Fleet inventory and per-device views: the board's on-demand read path. A third data shape beside the digest. The digest is a *cycle* payload -- built on the diff --git a/src/leapflow/hardware/observability/producer.py b/src/leapflow/hardware/observability/producer.py index 48e58ecc..cb9e8b74 100644 --- a/src/leapflow/hardware/observability/producer.py +++ b/src/leapflow/hardware/observability/producer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """The ``hardware`` monitor domain: one finding per cycle, carrying the digest. The only file here with a side effect, and the only one the daemon wires. It diff --git a/src/leapflow/hardware/observability/series.py b/src/leapflow/hardware/observability/series.py index 7ea3d41b..5848a3f9 100644 --- a/src/leapflow/hardware/observability/series.py +++ b/src/leapflow/hardware/observability/series.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Versioned contract for the physical-signal payload the board renders. Separate from the code that fills it, because the two change for different diff --git a/src/leapflow/hardware/outcome.py b/src/leapflow/hardware/outcome.py index 77e9015f..374466ef 100644 --- a/src/leapflow/hardware/outcome.py +++ b/src/leapflow/hardware/outcome.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Physical outcome learning: commanded value in, numeric prediction error out. This is where the physical domain earns its keep. In the UI domain "was the prediction diff --git a/src/leapflow/hardware/plugin.py b/src/leapflow/hardware/plugin.py index 8ff30dcb..1a43f050 100644 --- a/src/leapflow/hardware/plugin.py +++ b/src/leapflow/hardware/plugin.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hardware context plugin -- a ToolPlugin, not a sibling subsystem. Being an ordinary ``ToolPlugin`` is a deliberate structural choice, for two diff --git a/src/leapflow/hardware/preview.py b/src/leapflow/hardware/preview.py index a4d9eef4..8ab54937 100644 --- a/src/leapflow/hardware/preview.py +++ b/src/leapflow/hardware/preview.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Preview broker: shared, bounded, self-releasing access to a media channel. A preview is the one path in this subsystem where a device stays claimed across diff --git a/src/leapflow/hardware/providers/__init__.py b/src/leapflow/hardware/providers/__init__.py index c622f4d7..e1b9aafe 100644 --- a/src/leapflow/hardware/providers/__init__.py +++ b/src/leapflow/hardware/providers/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Provider factory table -- the context half of the pluggability mechanism. A provider answers "where does device knowledge come from". Adding an upstream diff --git a/src/leapflow/hardware/providers/host_provider.py b/src/leapflow/hardware/providers/host_provider.py index 442db79c..01394b26 100644 --- a/src/leapflow/hardware/providers/host_provider.py +++ b/src/leapflow/hardware/providers/host_provider.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Host provider: declares the machine LeapFlow runs on as one device. The host is not a special case in the protocol -- it is a device whose channel set diff --git a/src/leapflow/hardware/providers/media_provider.py b/src/leapflow/hardware/providers/media_provider.py index f60a7f8e..84b6f342 100644 --- a/src/leapflow/hardware/providers/media_provider.py +++ b/src/leapflow/hardware/providers/media_provider.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Media provider: declares each local camera and microphone as its own device. One device per physical instrument, unlike the host provider's single namespaced diff --git a/src/leapflow/hardware/providers/yaml_provider.py b/src/leapflow/hardware/providers/yaml_provider.py index efe4d4f2..4a5589b0 100644 --- a/src/leapflow/hardware/providers/yaml_provider.py +++ b/src/leapflow/hardware/providers/yaml_provider.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Declaration-file provider: reads hardware contexts from YAML on disk. The default and, before an upstream standard is available, the only source of diff --git a/src/leapflow/hardware/reading_store.py b/src/leapflow/hardware/reading_store.py index 53d5fcf3..b23c86bd 100644 --- a/src/leapflow/hardware/reading_store.py +++ b/src/leapflow/hardware/reading_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Durable storage for sampled hardware readings. Two tiers, because raw samples and long-term history have different lifetimes and diff --git a/src/leapflow/hardware/reference.py b/src/leapflow/hardware/reference.py index 13e79c59..a77cd987 100644 --- a/src/leapflow/hardware/reference.py +++ b/src/leapflow/hardware/reference.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Deterministic reference-document renderer. Turns a ``HardwareContext`` into the text an agent reads before operating a diff --git a/src/leapflow/hardware/registry.py b/src/leapflow/hardware/registry.py index d1ac9a06..cf3cb000 100644 --- a/src/leapflow/hardware/registry.py +++ b/src/leapflow/hardware/registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hardware registry: providers in, admitted contexts and transports out. Structurally the same shape as ``ToolPluginRegistry`` -- discover, validate, diff --git a/src/leapflow/hardware/replay.py b/src/leapflow/hardware/replay.py index 2d35d697..4a3f1c5c 100644 --- a/src/leapflow/hardware/replay.py +++ b/src/leapflow/hardware/replay.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Replay raw NDJSON segment files through the event detector. Reads the segment files produced by ``ReadingStore._append_raw`` and feeds each diff --git a/src/leapflow/hardware/risk.py b/src/leapflow/hardware/risk.py index 6428f92d..295b7f6d 100644 --- a/src/leapflow/hardware/risk.py +++ b/src/leapflow/hardware/risk.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Risk assessment for physical device actions. Every tier below is derived from declared data -- the channel's effect class and diff --git a/src/leapflow/hardware/stream.py b/src/leapflow/hardware/stream.py index e0d2cd4e..f8be6e25 100644 --- a/src/leapflow/hardware/stream.py +++ b/src/leapflow/hardware/stream.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Continuous sampling: raw readings in, derived events out. The layering here is the whole point, and it is a boundary decision rather than an diff --git a/src/leapflow/hardware/testing.py b/src/leapflow/hardware/testing.py index fe37481c..c9ca898d 100644 --- a/src/leapflow/hardware/testing.py +++ b/src/leapflow/hardware/testing.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Test-facing protocols and reusable conformance suite for hardware transports. This module holds no pytest dependency and nothing heavier than the standard diff --git a/src/leapflow/hardware/tools.py b/src/leapflow/hardware/tools.py index fb6d9a6b..6a348a63 100644 --- a/src/leapflow/hardware/tools.py +++ b/src/leapflow/hardware/tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """The eight hardware tools, derived from admitted contexts. The count is fixed regardless of how many devices exist. A rig of seven programs diff --git a/src/leapflow/hardware/transport.py b/src/leapflow/hardware/transport.py index 25b21cdd..4be045ae 100644 --- a/src/leapflow/hardware/transport.py +++ b/src/leapflow/hardware/transport.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hardware transport: the executable half of the Hardware Context Protocol. Six methods, nothing more. Deliberately narrower than ``ExecutionBackend``: a diff --git a/src/leapflow/hardware/transports/__init__.py b/src/leapflow/hardware/transports/__init__.py index d837ef2c..6397b648 100644 --- a/src/leapflow/hardware/transports/__init__.py +++ b/src/leapflow/hardware/transports/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Transport factory table -- the transport half of the pluggability mechanism. Adding support for a new southbound standard is a new module plus one row here. diff --git a/src/leapflow/hardware/transports/host.py b/src/leapflow/hardware/transports/host.py index fabe7968..f5ba3d20 100644 --- a/src/leapflow/hardware/transports/host.py +++ b/src/leapflow/hardware/transports/host.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Host transport: reads this machine's resource channels in-process. The counterpart to ``providers/host_provider.py``, and like it a thin shell over diff --git a/src/leapflow/hardware/transports/mcp.py b/src/leapflow/hardware/transports/mcp.py index 3fc2a4da..ea40e9d4 100644 --- a/src/leapflow/hardware/transports/mcp.py +++ b/src/leapflow/hardware/transports/mcp.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Transport that drives a device through an MCP server. The second southbound implementation, and therefore the first real test of the diff --git a/src/leapflow/hardware/transports/media.py b/src/leapflow/hardware/transports/media.py index 696ada8c..7e7cc1d6 100644 --- a/src/leapflow/hardware/transports/media.py +++ b/src/leapflow/hardware/transports/media.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Media transport: local capture behind the six-method contract, plus frames. Deliberately *not* named after a device. It is a generic mechanism -- local media diff --git a/src/leapflow/hardware/transports/mock.py b/src/leapflow/hardware/transports/mock.py index b82b070a..8a8a0cc0 100644 --- a/src/leapflow/hardware/transports/mock.py +++ b/src/leapflow/hardware/transports/mock.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Programmable in-memory transport for tests and dry runs. Deliberately device-agnostic: it holds channel values, applies writes, and diff --git a/src/leapflow/hardware/transports/python_callable.py b/src/leapflow/hardware/transports/python_callable.py index 82e51344..080df0ea 100644 --- a/src/leapflow/hardware/transports/python_callable.py +++ b/src/leapflow/hardware/transports/python_callable.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Transport that delegates to an externally supplied Python driver. This is the hardware-neutral escape hatch. A vendor SDK, a serial library, or a diff --git a/src/leapflow/hardware/transports/simulated.py b/src/leapflow/hardware/transports/simulated.py index adb501ba..3f26d6ba 100644 --- a/src/leapflow/hardware/transports/simulated.py +++ b/src/leapflow/hardware/transports/simulated.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Parameterised simulation transport for end-to-end and long-running tests. Like :mod:`leapflow.hardware.transports.mock`, this transport is entirely diff --git a/src/leapflow/hardware/trust.py b/src/leapflow/hardware/trust.py index e3c62efb..1a81becb 100644 --- a/src/leapflow/hardware/trust.py +++ b/src/leapflow/hardware/trust.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Per-(device, channel) trust gate for hardware write approval. Progressive trust for the physical domain: a channel that consistently produces diff --git a/src/leapflow/hub/__init__.py b/src/leapflow/hub/__init__.py index ac8dc72f..236cc674 100644 --- a/src/leapflow/hub/__init__.py +++ b/src/leapflow/hub/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapFlow Hub — cloud collaboration for skill sharing and multi-device sync. Public API: diff --git a/src/leapflow/hub/backends/__init__.py b/src/leapflow/hub/backends/__init__.py index 6a2aaf2f..edc5baf9 100644 --- a/src/leapflow/hub/backends/__init__.py +++ b/src/leapflow/hub/backends/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hub backend implementations.""" diff --git a/src/leapflow/hub/backends/github.py b/src/leapflow/hub/backends/github.py index 1d98d948..3aabb16c 100644 --- a/src/leapflow/hub/backends/github.py +++ b/src/leapflow/hub/backends/github.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """GitHub Hub backend — REST API-based skill push/pull/search. Implements HubBackend Protocol using GitHub REST API (Contents + Repos). diff --git a/src/leapflow/hub/backends/huggingface.py b/src/leapflow/hub/backends/huggingface.py index 87962480..c695493a 100644 --- a/src/leapflow/hub/backends/huggingface.py +++ b/src/leapflow/hub/backends/huggingface.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """HuggingFace Hub backend — placeholder for Phase 2. Will be activated when huggingface-hub SDK integration is ready. diff --git a/src/leapflow/hub/backends/local.py b/src/leapflow/hub/backends/local.py index 5754cf0e..2e3e9ab6 100644 --- a/src/leapflow/hub/backends/local.py +++ b/src/leapflow/hub/backends/local.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Local filesystem backend — for testing and offline use. Stores skill bundles as directories on the local filesystem, enabling diff --git a/src/leapflow/hub/backends/modelscope.py b/src/leapflow/hub/backends/modelscope.py index 4509dc77..ed515dff 100644 --- a/src/leapflow/hub/backends/modelscope.py +++ b/src/leapflow/hub/backends/modelscope.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """ModelScope Hub backend implementation. Provides push/pull/search operations against ModelScope Hub (modelscope.cn). diff --git a/src/leapflow/hub/client.py b/src/leapflow/hub/client.py index e7d3bcae..1bb6750c 100644 --- a/src/leapflow/hub/client.py +++ b/src/leapflow/hub/client.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hub client facade — routes operations to the appropriate backend. Provides a unified interface for all Hub operations, delegating to diff --git a/src/leapflow/hub/protocol.py b/src/leapflow/hub/protocol.py index ce151405..708b047d 100644 --- a/src/leapflow/hub/protocol.py +++ b/src/leapflow/hub/protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hub protocol definitions — backend-agnostic types for cloud skill collaboration. Defines the HubBackend Protocol and all shared data structures used across diff --git a/src/leapflow/hub/security.py b/src/leapflow/hub/security.py index da8bd73d..40ee5780 100644 --- a/src/leapflow/hub/security.py +++ b/src/leapflow/hub/security.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Content sanitization and security audit for hub operations. Scans SkillBundle content for sensitive data (before push) and dangerous diff --git a/src/leapflow/hub/serializer.py b/src/leapflow/hub/serializer.py index c4bee6aa..f6074d52 100644 --- a/src/leapflow/hub/serializer.py +++ b/src/leapflow/hub/serializer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Serialize/deserialize SkillBundle for hub transport. Converts between SkillLibraryStore records and portable SkillBundle format. diff --git a/src/leapflow/hub/sync.py b/src/leapflow/hub/sync.py index c2c20c3f..254c77f0 100644 --- a/src/leapflow/hub/sync.py +++ b/src/leapflow/hub/sync.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Sync engine — bidirectional skill synchronization between local and hub. Computes diff-based sync plans and executes push/pull actions to keep local diff --git a/src/leapflow/layout.py b/src/leapflow/layout.py index 11cc5e35..8315d589 100644 --- a/src/leapflow/layout.py +++ b/src/leapflow/layout.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Canonical filesystem layout for LeapFlow runtime data. This module is the single source of truth for paths under the LeapFlow data @@ -430,6 +431,14 @@ def plugin_outcomes_path(self) -> Path: # Profile-scoped execution outcome audit for adaptive plugin lifecycle governance. return self.root / "plugins" / "outcomes.json" + @property + def distilled_knowledge_path(self) -> Path: + # Profile-scoped store for what the teacher distilled about the environment. + # Beside the other capability state because it shares their lifecycle: it is + # learned per profile, describes that profile's world, and is meaningless to + # copy elsewhere. + return self.root / "plugins" / "distilled_knowledge.json" + @property def capability_plans_path(self) -> Path: # Profile-scoped adaptive capability decision history: requirements, diff --git a/src/leapflow/learning/__init__.py b/src/leapflow/learning/__init__.py index cdaf2314..466f3239 100644 --- a/src/leapflow/learning/__init__.py +++ b/src/leapflow/learning/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skill learning layer — distillation, code generation, feedback, and active learning.""" from leapflow.learning.cold_start import ColdStartConfig, ColdStartManager, ColdStartPhase diff --git a/src/leapflow/learning/active_learning.py b/src/leapflow/learning/active_learning.py index 0eb793af..76c28631 100644 --- a/src/leapflow/learning/active_learning.py +++ b/src/leapflow/learning/active_learning.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Active learning — detect near-match skills and suggest updates. Two-phase similarity pipeline: diff --git a/src/leapflow/learning/capability_effect_verifier.py b/src/leapflow/learning/capability_effect_verifier.py index fcfbaa93..caf943fc 100644 --- a/src/leapflow/learning/capability_effect_verifier.py +++ b/src/leapflow/learning/capability_effect_verifier.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Verify an acquired capability by its effect, and reclaim what never works. Two gaps this closes, both recorded by the EVO-02 experiments: @@ -38,13 +39,48 @@ logger = logging.getLogger(__name__) -#: Result keys a tool may use to report what it observably did. This is the whole -#: declaration channel for confirmation: without one of these, a *successful* call is -#: ``unverifiable`` (we do not know whether the effect landed) while a *failed* call -#: still refutes. Several spellings are accepted because the convention post-dates -#: existing tools, and a tool that already says ``observed_effect`` should not have to -#: be rewritten to be verifiable. -OBSERVED_EFFECT_KEYS: tuple[str, ...] = ("observed_effect", "effect") +#: The single result key a tool uses to report what it observably did. This is the +#: whole declaration channel for confirmation: without it, a *successful* call is +#: ``unverifiable`` (we do not know whether the effect landed) while a *failed* +#: call still refutes. +#: +#: ``effect`` was accepted here too and had to be removed: it is already in use +#: across the tree with an entirely different meaning -- a risk *class* +#: (``"effect": "write"`` in self-management) and a hardware channel *type* +#: (``channel.effect``). With single-token overlap sufficient for a match, an +#: expectation reading "write the message to the channel" was confirmed by a tool +#: reporting ``effect="write"``, producing a decided ``verified=True`` that granted +#: trust for evidence which never existed. +#: +#: One narrow key that nothing yet emits is the honest state: every verdict +#: abstains until a handler opts in, and the board reports that abstention rate +#: rather than a sprinkling of fabricated confirmations. +OBSERVED_EFFECT_KEYS: tuple[str, ...] = ("observed_effect",) + + +def declare_effect(effect: str) -> dict[str, str]: + """The writer half of the effect channel, for a handler's success result. + + Used as ``return {"ok": True, ..., **declare_effect(f"wrote {n} bytes to {name}")}``. + + The key is spelled in exactly one place, here, beside the reader that consumes + it. Spelling it at each call site is how the two halves drifted before: the + channel was narrowed to ``observed_effect`` while the plugin generator still + taught handlers to write ``effect``, so every generated plugin reported through + a key nothing read and abstained forever. + + An empty description returns no key at all rather than an empty string. Silence + is a legitimate answer -- the verifier reads it as *unverifiable* rather than as + failure -- and it must stay distinguishable from a handler that tried to describe + its effect and had nothing to say. + + What belongs here is what was *observed*, in the same terms a requirement would + state it: a measured byte count, the value a key now holds, an id the remote + returned. Never a restatement of the request -- an invented description would be + compared against the expectation and could confirm work that never happened. + """ + described = str(effect or "").strip() + return {OBSERVED_EFFECT_KEYS[0]: described} if described else {} #: Reasons a verification can fail, kept as constants so callers can branch on #: them without matching prose. diff --git a/src/leapflow/learning/capability_gap_detector.py b/src/leapflow/learning/capability_gap_detector.py index 4f5197bc..9ddce383 100644 --- a/src/leapflow/learning/capability_gap_detector.py +++ b/src/leapflow/learning/capability_gap_detector.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Capability gap detection for plugin self-evolution. The detector is intentionally side-effect free: it only turns structured runtime @@ -84,6 +85,7 @@ def proposal_from_evolution_intent( intent: EvolutionIntent, *, risk_ceiling: RiskLevel = MODEL_AUTHORED_RISK_CEILING, + incumbent: str = "", ) -> PluginProposal: """Create a side-effect-free proposal from a world-model intent. @@ -93,6 +95,15 @@ def proposal_from_evolution_intent( ``plugin_generate`` (validated code, no install) and ``plugin_install`` (approval-gated). Creating a proposal mutates nothing. + ``incumbent`` names the plugin that already provides this capability, when one + does. Passing it makes the proposal a *rival* rather than a gap fill, and that + distinction has to be carried in the identity: the plugin id is otherwise + derived from the capability alone, so a rival for ``chat.reply`` would be named + exactly what the incumbent's own generated name would be and the two could + never coexist -- which is the whole point of proposing a rival. Whether a + capability already has a provider is a registry fact supplied by the caller, + never inferred from the hypothesis text. + The proposal's risk level is the *clamped* ceiling, never the level the authoring model asked for; the original request is preserved in the evidence metadata for audit. @@ -114,6 +125,12 @@ def proposal_from_evolution_intent( metadata["requested_max_risk_level"] = str(intent.max_risk_level) if intent.evidence_ids: metadata["evidence_ids"] = ",".join(intent.evidence_ids) + rival_of = str(incumbent or "").strip() + if rival_of: + # Recorded in the evidence, not only in the identity: an approver reading + # this proposal has to see that it competes with a named incumbent rather + # than filling an empty slot. + metadata["replaces"] = rival_of evidence = GapEvidence.create( WORLD_MODEL_INTENT, @@ -124,13 +141,21 @@ def proposal_from_evolution_intent( tool_name = _slug(intent.capability, fallback="generated_tool") mutates = effective in {"high", "mutating", "external"} proposed_tool = ProposedToolSpec( - name=tool_name, + name=(f"{tool_name}_alt_{intent.intent_id[:8]}" if rival_of else tool_name), description=intent.expected_effect or intent.hypothesis, risk_level=effective, # type: ignore[arg-type] mutates_state=mutates, ) + base_plugin_id = ( + # Discriminated by the intent so successive rivals for the same capability + # stay distinct artifacts; a gap fill keeps the stable capability-derived + # name, which is the dedup a genuinely missing capability wants. + f"{tool_name}_alt_{intent.intent_id[:8]}_plugin" + if rival_of + else f"{tool_name}_plugin" + ) return PluginProposal.create( - plugin_id=_slug(f"{tool_name}_plugin", fallback="generated_tool_plugin"), + plugin_id=_slug(base_plugin_id, fallback="generated_tool_plugin"), capability_summary=intent.hypothesis, gap_type="tool_plugin", risk_level=effective, # type: ignore[arg-type] diff --git a/src/leapflow/learning/capability_observation.py b/src/leapflow/learning/capability_observation.py index 3524feab..8d6a7ea9 100644 --- a/src/leapflow/learning/capability_observation.py +++ b/src/leapflow/learning/capability_observation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Structured capability observations for adaptive plugin evolution. The observation layer is intentionally side-effect free. It accepts structured @@ -12,6 +13,7 @@ from typing import Any, Iterable, Mapping, Sequence from leapflow.domain.capability_requirement import CapabilityRequirement +from leapflow.domain.evolution_intent import WORLD_MODEL_INTENT from leapflow.domain.environment_fingerprint import EnvironmentFingerprint from leapflow.learning.capability_gap_detector import CapabilityGapDetector @@ -19,6 +21,36 @@ # default so behaviour is unchanged unless a classifier is explicitly supplied. DEFAULT_ACCEPTED_EVIDENCE = frozenset({"unknown_tool"}) +#: Evidence that an *existing* provider of a capability is performing badly. Emitted +#: by lifecycle governance when a failure leaves the plugin still in service, so it +#: reports "what serves this capability is inadequate" rather than "nothing serves +#: it". Opt-in like every non-default kind. +CAPABILITY_DEGRADED = "capability_degraded" + +#: Evidence kinds a successful resolution does **not** retire. +#: +#: Retirement means "the gap this evidence reported is closed". For ``unknown_tool`` +#: that is exactly what a provider existing proves. For degradation it proves nothing: +#: the provider that exists is the thing being reported. Without this distinction a +#: degradation observation is retired on the very next turn -- resolution finds the +#: incumbent, calls the capability satisfied, and erases the record of it failing. +EVIDENCE_SURVIVING_RESOLUTION = frozenset({CAPABILITY_DEGRADED}) + +#: Failure classes the retry layer already owns, so degradation evidence carrying one +#: never reaches the teacher. +#: +#: A timeout or a dropped connection says nothing about whether the implementation is +#: right for this environment -- ``RecoveryAction.RETRY_WITH_BACKOFF`` handles it inside +#: the turn. Forwarding it anyway would ask a hindsight evaluator to adjudicate a +#: transient, and the only answer it could give that changes anything is "rebuild", +#: which is the most expensive response in the system applied to a problem that already +#: resolved itself. +#: Every member must be a class some classifier actually emits, and a test asserts it. +#: ``"rate_limit"`` was in here with no producer anywhere -- harmless, but it claimed to +#: filter something never seen, which is how a set like this stops being readable as a +#: statement about the system. +RETRY_OWNED_FAILURE_CLASSES = frozenset({"timeout", "connection_error", "transient"}) + @dataclass(frozen=True) class CapabilityEvidenceClassifier: @@ -48,12 +80,28 @@ def from_kinds(cls, kinds: Iterable[str] | None = None) -> "CapabilityEvidenceCl @classmethod def from_settings(cls, settings: Any) -> "CapabilityEvidenceClassifier": - """Build from a Settings-like object's ``accepted_evidence_kinds``. - - Returns the default (``unknown_tool`` only) when the setting is absent or - empty, so an operator must opt in before any new trigger becomes live. + """Build from ``evolution_enabled``, widened by ``accepted_evidence_kinds``. + + One switch, because two were one too many. ``accepted_evidence_kinds`` is a tuple of + internal kind names surfaced under the key ``accepted.evidence_kinds`` -- a section + that names nothing -- and a user who turned self-evolution on and then found nothing + happened would have no way to guess that a second, differently-named setting also + had to list ``world_model_intent``. So the switch admits it, and the tuple remains + for the finer-grained case: structural kinds like ``interface_drift`` come from an + environment probe rather than the world model and are opted into separately. + + Admission is a *trigger*, never a permission. Every admitted kind still traverses + resolution, risk, approval, validation and trust unchanged. """ - return cls.from_kinds(getattr(settings, "accepted_evidence_kinds", None)) + kinds = tuple(getattr(settings, "accepted_evidence_kinds", None) or ()) + if getattr(settings, "evolution_enabled", False): + # Widen, never replace. ``from_kinds`` treats a non-empty tuple as the whole + # accepted set, so appending alone would have *dropped* ``unknown_tool`` -- + # turning self-evolution on would have silently disabled the trigger that was + # already working, and the chain would have looked more capable while covering + # less. + kinds = (*DEFAULT_ACCEPTED_EVIDENCE, *kinds, WORLD_MODEL_INTENT) + return cls.from_kinds(kinds) def accepts(self, result: Mapping[str, Any] | None) -> bool: return isinstance(result, Mapping) and str(result.get("error_type") or "") in self.accepted @@ -205,6 +253,49 @@ def requirements( ] return self._detector.requirements_from_tool_results(results, min_count=1) + def degraded_capabilities(self, *, limit: int = 50) -> tuple[dict[str, Any], ...]: + """The still-serving providers that are failing, as facts for the teacher. + + One reader for this evidence, so the two rules that make it usable live in one + place instead of being re-derived by each consumer: + + * **Retry-owned failures are excluded.** A timeout says nothing about whether + the implementation fits the environment, and the only verdict a hindsight + evaluator could give that changes anything is "rebuild" -- the most expensive + response in the system, applied to something that already resolved itself. + * **The environment fingerprint travels with each fact.** One application + upgrade breaks every capability bound to the old affordances, and without the + fingerprint those arrive as N unrelated degradations. The teacher would then + answer N times and could propose N rebuilds where the truth is one root cause + and, usually, one rebind. + + Returns plain dicts rather than a domain type because this is a projection for a + prompt, not a decision: nothing downstream should be able to act on it directly. + """ + facts: list[dict[str, Any]] = [] + for record in self._store.unresolved(min_count=1, limit=limit): + result = record.get("result") or {} + if str(result.get("error_type") or "") != CAPABILITY_DEGRADED: + continue + if str(result.get("failure_class") or "") in RETRY_OWNED_FAILURE_CLASSES: + continue + capability = str(result.get("capability") or "").strip() + if not capability: + continue + facts.append( + { + "capability": capability, + "plugin_id": str(result.get("plugin_id") or ""), + "failure_streak": int(result.get("failure_streak") or 0), + "failure_class": str(result.get("failure_class") or ""), + "trust_level": str(result.get("trust_level") or ""), + # The environment the failures were seen in, so a compound change + # is recognisable as one transition rather than N coincidences. + "environment": dict(record.get("environment") or {}), + } + ) + return tuple(facts) + def resolve_capability( self, capability: str, *, reason: str = "", limit: int = 50 ) -> tuple[str, ...]: @@ -219,6 +310,11 @@ def resolve_capability( so an observation is retired only when it genuinely maps to the resolved capability -- never by string-matching the raw payload. Returns the ids of the observations retired. + + Evidence in :data:`EVIDENCE_SURVIVING_RESOLUTION` is skipped: it reports that + the *existing* provider is inadequate, so finding that provider does not + address it. Retiring it here would delete the degradation record at the first + resolution after it was written. """ target = str(capability or "").strip() if not target: @@ -228,9 +324,10 @@ def resolve_capability( observation_id = str(record.get("observation_id") or "") if not observation_id: continue - derived = self._detector.requirements_from_tool_results( - [record.get("result") or {}], min_count=1 - ) + result = record.get("result") or {} + if str(result.get("error_type") or "") in EVIDENCE_SURVIVING_RESOLUTION: + continue + derived = self._detector.requirements_from_tool_results([result], min_count=1) if any(requirement.capability == target for requirement in derived): if self._store.mark_status( observation_id, "resolved", reason=reason or f"{target} resolved" @@ -244,5 +341,7 @@ def resolve_capability( "CapabilityObservation", "CapabilityObservationBuffer", "CapabilityObservationService", + "CAPABILITY_DEGRADED", "DEFAULT_ACCEPTED_EVIDENCE", + "EVIDENCE_SURVIVING_RESOLUTION", ] diff --git a/src/leapflow/learning/codegen.py b/src/leapflow/learning/codegen.py index 04cf3100..93bfdfc6 100644 --- a/src/leapflow/learning/codegen.py +++ b/src/leapflow/learning/codegen.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LLM-driven skill code generation from distillation candidates. Transforms DistillationCandidate (descriptive JSON) into executable Python async functions diff --git a/src/leapflow/learning/cold_start.py b/src/leapflow/learning/cold_start.py index d671262a..5e592108 100644 --- a/src/leapflow/learning/cold_start.py +++ b/src/leapflow/learning/cold_start.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cold start strategy — handles system behavior when learning data is insufficient. Design goal (from Active Learning Design doc): "冷启动与适配成本极低 — 首次使用即开始学习" diff --git a/src/leapflow/learning/compatibility/__init__.py b/src/leapflow/learning/compatibility/__init__.py index d322784b..ce3ba439 100644 --- a/src/leapflow/learning/compatibility/__init__.py +++ b/src/leapflow/learning/compatibility/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Plugin Compatibility Assessment Engine. Evaluates foreign plugins (primarily from deepseek-harness ecosystem) diff --git a/src/leapflow/learning/compatibility/adapter_generator.py b/src/leapflow/learning/compatibility/adapter_generator.py index 7fda3561..24b9dad8 100644 --- a/src/leapflow/learning/compatibility/adapter_generator.py +++ b/src/leapflow/learning/compatibility/adapter_generator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Deterministic wrapper generation for runtime-discovered DSH plugins. A manifest cannot prove a foreign tool exists or is executable. Wrapper source diff --git a/src/leapflow/learning/compatibility/manifest_converter.py b/src/leapflow/learning/compatibility/manifest_converter.py index 8c7c5fd5..143faa01 100644 --- a/src/leapflow/learning/compatibility/manifest_converter.py +++ b/src/leapflow/learning/compatibility/manifest_converter.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DSH package.json → LeapFlow compatibility descriptor conversion. This is metadata for assessment and audit, not a ``MarketplaceClient`` install diff --git a/src/leapflow/learning/compatibility/pipeline.py b/src/leapflow/learning/compatibility/pipeline.py index ba482838..51a0df3a 100644 --- a/src/leapflow/learning/compatibility/pipeline.py +++ b/src/leapflow/learning/compatibility/pipeline.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Assessment pipeline orchestrator. Entry point for the Plugin Compatibility Assessment Engine. diff --git a/src/leapflow/learning/compatibility/protocol.py b/src/leapflow/learning/compatibility/protocol.py index 97c3be83..2dbd185c 100644 --- a/src/leapflow/learning/compatibility/protocol.py +++ b/src/leapflow/learning/compatibility/protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Protocol and data definitions for Plugin Compatibility Assessment Engine. Defines the core domain types used across all assessment stages. diff --git a/src/leapflow/learning/compatibility/source_inspector.py b/src/leapflow/learning/compatibility/source_inspector.py index b1ec7040..30aae8a7 100644 --- a/src/leapflow/learning/compatibility/source_inspector.py +++ b/src/leapflow/learning/compatibility/source_inspector.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Inspect real DSH/Cordis source bundles without executing foreign code. Static inspection establishes source identity, bounds, integrity and component diff --git a/src/leapflow/learning/compatibility/stages/__init__.py b/src/leapflow/learning/compatibility/stages/__init__.py index 7568c31c..78bfa940 100644 --- a/src/leapflow/learning/compatibility/stages/__init__.py +++ b/src/leapflow/learning/compatibility/stages/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Assessment pipeline stages.""" from typing import List, Protocol, runtime_checkable diff --git a/src/leapflow/learning/compatibility/stages/category_resolver.py b/src/leapflow/learning/compatibility/stages/category_resolver.py index ab2e3b6d..419759f6 100644 --- a/src/leapflow/learning/compatibility/stages/category_resolver.py +++ b/src/leapflow/learning/compatibility/stages/category_resolver.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Stage 2: Category Resolver. Looks up the manifest's category in the PLUGGABILITY_TAXONOMY and diff --git a/src/leapflow/learning/compatibility/stages/dependency_checker.py b/src/leapflow/learning/compatibility/stages/dependency_checker.py index 0ab93832..76da4423 100644 --- a/src/leapflow/learning/compatibility/stages/dependency_checker.py +++ b/src/leapflow/learning/compatibility/stages/dependency_checker.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Stage 4: Dependency Checker. Checks declared_dependencies against what LeapFlow can provide. diff --git a/src/leapflow/learning/compatibility/stages/execution_model.py b/src/leapflow/learning/compatibility/stages/execution_model.py index 3a8ca004..1c4e8be0 100644 --- a/src/leapflow/learning/compatibility/stages/execution_model.py +++ b/src/leapflow/learning/compatibility/stages/execution_model.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Stage 5: Execution Model Analyzer. Checks execution_model and source_language compatibility with LeapFlow's diff --git a/src/leapflow/learning/compatibility/stages/interface_analyzer.py b/src/leapflow/learning/compatibility/stages/interface_analyzer.py index da788afe..bde30552 100644 --- a/src/leapflow/learning/compatibility/stages/interface_analyzer.py +++ b/src/leapflow/learning/compatibility/stages/interface_analyzer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Stage 3: Interface Analyzer. Checks whether the plugin's declared_interfaces list includes methods/attributes diff --git a/src/leapflow/learning/compatibility/stages/manifest_parser.py b/src/leapflow/learning/compatibility/stages/manifest_parser.py index 37fcb447..298eb5b3 100644 --- a/src/leapflow/learning/compatibility/stages/manifest_parser.py +++ b/src/leapflow/learning/compatibility/stages/manifest_parser.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Stage 1: Manifest Parser. Parses raw manifest input (dict) into a PluginManifestInput. diff --git a/src/leapflow/learning/compatibility/stages/security_classifier.py b/src/leapflow/learning/compatibility/stages/security_classifier.py index 5e312653..beb24142 100644 --- a/src/leapflow/learning/compatibility/stages/security_classifier.py +++ b/src/leapflow/learning/compatibility/stages/security_classifier.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Stage 6: Security Classifier. Assesses security risk from declared permissions and recommends diff --git a/src/leapflow/learning/compatibility/taxonomy.py b/src/leapflow/learning/compatibility/taxonomy.py index d802f691..714e56b2 100644 --- a/src/leapflow/learning/compatibility/taxonomy.py +++ b/src/leapflow/learning/compatibility/taxonomy.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Pluggability Boundary Taxonomy — the authoritative decision table. Maps DSH plugin category strings to LeapFlow compatibility verdicts. diff --git a/src/leapflow/learning/compatibility/verdict.py b/src/leapflow/learning/compatibility/verdict.py index 21d995da..5ede18ed 100644 --- a/src/leapflow/learning/compatibility/verdict.py +++ b/src/leapflow/learning/compatibility/verdict.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Verdict Synthesizer. Takes all stage results and produces the final CompatibilityReport verdict. diff --git a/src/leapflow/learning/degradation_sink.py b/src/leapflow/learning/degradation_sink.py new file mode 100644 index 00000000..a5da6b61 --- /dev/null +++ b/src/leapflow/learning/degradation_sink.py @@ -0,0 +1,282 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Wire plugin health into capability-scoped evidence. + +``LifecycleGovernor`` deliberately holds no registry: it knows a plugin failed, not what +that plugin was *for*. The teacher needs the opposite -- which **capability** is degraded, +because a capability is what a rival could be built for and what knowledge can be attached +to. This module is that translation, and it is the reason the governor takes a sink rather +than reaching for the registry itself. + +It exists because the chain it completes was inert. ``self.lifecycle_governor`` was never +assigned anywhere in production, so the sweep resolved it to ``None``, ``record_outcome`` +was never called, and the degradation evidence that the teacher prompt, the challenger +identity, and the proposal path were all built to consume was never produced. Every unit +test passed, because every unit test constructed the governor itself. +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, Mapping + +logger = logging.getLogger(__name__) + + +def declared_capabilities_by_plugin(registry: Any) -> dict[str, tuple[str, ...]]: + """Map plugin id to the capabilities its live tools declare. + + Delegates to the resolver's own candidate builder rather than walking the registry + again. That builder applies two filters this translation must not lose: first-wins + name arbitration (a shadowed tool is not live) and handler presence (an unbound tool + is not callable). Re-implementing the walk would let a plugin be degraded under a + capability it does not actually serve in this process. + + Declaration only -- nothing is inferred from a tool's name, which is the rule that + keeps a capability a thing a plugin *claims* rather than a thing a substring guessed. + """ + from leapflow.plugins.capability_resolver import candidates_from_registry + + declared: dict[str, set[str]] = {} + for candidate in candidates_from_registry(registry): + if candidate.provides_capabilities: + declared.setdefault(candidate.plugin_id, set()).update( + candidate.provides_capabilities + ) + return {plugin_id: tuple(sorted(names)) for plugin_id, names in declared.items()} + + +def build_degradation_sink( + *, + intake: Any, + registry_provider: Callable[[], Any], + knowledge_store: Any = None, + environment_provider: Callable[[], Mapping[str, Any]] | None = None, +) -> Callable[..., None]: + """Return the sink that turns plugin health into capability evidence. + + Both directions are handled, because a health signal that only fires one way has no + way back: + + * **A non-zero streak** writes a ``capability_degraded`` observation per declared + capability, so the teacher learns that something is failing *while still serving* -- + the state between healthy and quarantined, which had no expression before. + * **A zero streak** retracts any distilled knowledge for those capabilities. This is + the one retirement neither supersession nor expiry covers: no newer verdict is + coming precisely because there is no longer anything wrong, so knowledge describing + the failure would otherwise outlive the failure and mislead every later session. + + The registry is resolved through a callable rather than captured, because plugins are + installed and reloaded at runtime and a snapshot taken at wiring time would report a + capability set that has since changed. + """ + + def sink( + *, + plugin_id: str, + failure_streak: int, + trust_level: str, + failure_class: str = "", + ) -> None: + try: + declared = declared_capabilities_by_plugin(registry_provider()) + except Exception: # noqa: BLE001 - governance reporting is advisory + logger.debug("degradation_sink: registry unavailable", exc_info=True) + return + capabilities = declared.get(str(plugin_id), ()) + if not capabilities: + # A plugin that declares no capability cannot be degraded *as* one. Nothing + # to report, and inventing a name from the plugin id would put a fabricated + # capability in front of the teacher. + return + + if int(failure_streak) <= 0: + _retire(knowledge_store, capabilities, plugin_id) + return + + environment: Mapping[str, Any] = {} + if environment_provider is not None: + try: + environment = environment_provider() or {} + except Exception: # noqa: BLE001 - a fingerprint is context, not a gate + environment = {} + for capability in capabilities: + try: + intake.observe_result( + { + "error_type": "capability_degraded", + "capability": capability, + "plugin_id": str(plugin_id), + "failure_streak": int(failure_streak), + "trust_level": str(trust_level), + "failure_class": str(failure_class or ""), + }, + environment=environment, + ) + except Exception: # noqa: BLE001 - one capability must not stop the rest + logger.debug( + "degradation_sink: could not record %s", capability, exc_info=True + ) + + return sink + + +def build_proposal_sink(*, queue: Any) -> Callable[[Any], str]: + """Return the sink that turns an accepted acquisition into a queued proposal. + + The last hop of the acquisition chain, and it was missing: the driver derived an + ``EvolutionIntent`` from an ``acquire`` verdict, turned it into a requirement, and + stopped. Nothing enqueued it, so resolution reported the capability unmet forever and + the teacher's most expensive verdict -- the only one that leads to code -- had no + effect at all. + + Queueing is not acting. The queue is read by the evolution dashboard and by the + ``self_management`` tools, both of which pass through approval before anything is + generated, so this hop makes the proposal *visible and actionable* rather than + executed. That separation is why the sink can be wired by default while generation + stays governed. + """ + + def sink(proposal: Any) -> str: + requirement = _requirement_from(proposal) + if requirement is None: + # Without a capability the queue has nothing to deduplicate on and resolution + # has nothing to satisfy, so the item could never be closed. + logger.debug( + "proposal_sink: refused proposal without a capability (%r)", + getattr(proposal, "proposal_id", ""), + ) + return "" + evidence = tuple(getattr(proposal, "evidence", ()) or ()) + metadata = dict(getattr(evidence[0], "metadata", {})) if evidence else {} + try: + item = queue.enqueue( + requirements=(requirement,), + source="world_model", + risk={"max_risk_level": requirement.max_risk_level}, + metadata={ + "plugin_id": str(getattr(proposal, "plugin_id", "")), + "capability_summary": str(getattr(proposal, "capability_summary", "")), + # Carried so a reviewer can see what a challenger is challenging, and + # so a rival stays distinguishable from a gap fill for the same + # capability. + "replaces": str(metadata.get("replaces", "")), + }, + ) + except Exception: # noqa: BLE001 - queueing must not fail the session + logger.debug("proposal_sink: could not enqueue", exc_info=True) + return "" + return str(getattr(item, "proposal_id", "")) + + return sink + + +def _requirement_from(proposal: Any) -> Any: + """Rebuild the requirement the queue keys on, from the proposal's own evidence. + + ``max_risk_level`` is passed explicitly because the domain default is ``external`` -- + the most permissive value there is. Omitting it would let a proposal that was clamped + to ``read_only`` enter the queue asking for everything, which is the opposite of what + the clamp exists for. + + ``requirement_id`` is derived from the capability rather than minted fresh, because the + queue deduplicates on a hash of the requirement payload. A new uuid on every rebuild + defeated that silently: the same capability enqueued a new proposal every session, so a + reviewer would face a growing pile of identical items and the health of the queue would + measure how long the process had been running. + """ + from leapflow.domain.capability_requirement import CapabilityRequirement + + evidence = tuple(getattr(proposal, "evidence", ()) or ()) + metadata = dict(getattr(evidence[0], "metadata", {})) if evidence else {} + capability = str(metadata.get("capability") or "").strip() + if not capability: + return None + return CapabilityRequirement.create( + capability, + "world_model", + evidence=str(getattr(proposal, "capability_summary", "") or capability), + max_risk_level=str(getattr(proposal, "risk_level", "read_only")), + requirement_id=f"req-wm-{capability}", + ) + + +def build_alternatives_provider( + *, registry_provider: Callable[[], Any], affordances_provider: Callable[[], Any] | None = None +) -> Callable[[str, str], tuple[dict[str, Any], ...]]: + """Return a reader for the *other* providers of a capability, and whether each fits. + + Without this the teacher is asked to choose between two actions whose definitions are + exactly the fact it was never given: + + rebind -- "another installed capability already covers the new environment" + acquire -- "nothing installed covers this" + + It was shown a flat list of global capability *names* and nothing about how many + providers a capability has or whether any of them can run here. Measured on a real + model: ``rebind`` on 3 of 3 trials of a unit whose candidate set had one entry, then + three different answers in three trials once the catalogue stopped implying that + everything listed fits. That is what choosing without the deciding fact looks like. + + Admissibility comes from the same declaration the resolver scores on + (``requires_environment_affordances``), so the teacher and the selection layer cannot + disagree about what is available. Reported, never enforced: the teacher may still + answer ``acquire`` when an alternative exists but is a poor fit, which is a judgement + only it can make. + """ + + def alternatives(capability: str, incumbent: str = "") -> tuple[dict[str, Any], ...]: + from leapflow.plugins.capability_resolver import candidates_from_registry + + try: + present = frozenset(str(a) for a in (affordances_provider() or ())) if affordances_provider else frozenset() + except Exception: # noqa: BLE001 - unknown affordances must not hide alternatives + present = frozenset() + try: + candidates = candidates_from_registry(registry_provider()) + except Exception: # noqa: BLE001 - context, never a gate + logger.debug("alternatives: registry unavailable", exc_info=True) + return () + rows: list[dict[str, Any]] = [] + for candidate in candidates: + if capability not in candidate.provides_capabilities: + continue + if incumbent and candidate.plugin_id == incumbent: + continue + required = frozenset(candidate.requires_environment_affordances) + rows.append( + { + "plugin_id": candidate.plugin_id, + "tool_name": candidate.tool_name, + # Unknown affordances read as "fits": claiming a candidate does not fit + # because the environment could not be described would push every + # verdict toward acquire, which is the expensive direction. + "fits_here": (not required) or (not present) or required <= present, + "requires": tuple(sorted(required)), + } + ) + return tuple(rows) + + return alternatives + + +def _retire(knowledge_store: Any, capabilities: tuple[str, ...], plugin_id: str) -> None: + """Drop knowledge about capabilities that are working again.""" + if knowledge_store is None: + return + for capability in capabilities: + try: + knowledge_store.retract( + capability, reason=f"{plugin_id} succeeded; streak reset" + ) + except Exception: # noqa: BLE001 - retirement is advisory + logger.debug( + "degradation_sink: could not retract %s", capability, exc_info=True + ) + + +__all__ = [ + "build_alternatives_provider", + "build_degradation_sink", + "build_proposal_sink", + "declared_capabilities_by_plugin", +] diff --git a/src/leapflow/learning/difficulty_calibration.py b/src/leapflow/learning/difficulty_calibration.py index 218103d1..156c99d4 100644 --- a/src/leapflow/learning/difficulty_calibration.py +++ b/src/leapflow/learning/difficulty_calibration.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """S3-L2: offline difficulty calibration analysis (report-only). Consumes the adaptive-depth learning signals captured per turn (S3-L1) from the diff --git a/src/leapflow/learning/distiller.py b/src/leapflow/learning/distiller.py index 601436a3..2bf99755 100644 --- a/src/leapflow/learning/distiller.py +++ b/src/leapflow/learning/distiller.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skill distillation — extract reusable skills from trajectories and transcripts. Supports two pathways: diff --git a/src/leapflow/learning/doc_generator.py b/src/leapflow/learning/doc_generator.py index 043e807f..368dabbf 100644 --- a/src/leapflow/learning/doc_generator.py +++ b/src/leapflow/learning/doc_generator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skill document generation — transform DistillationCandidates into SKILL.md. Two strategies (same pattern as codegen.py): diff --git a/src/leapflow/learning/document.py b/src/leapflow/learning/document.py index 138396ba..b534b72e 100644 --- a/src/leapflow/learning/document.py +++ b/src/leapflow/learning/document.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Standard Skill Document model (Anthropic Agent Skills format). Provides the data model, renderer, and parser for SKILL.md files that conform diff --git a/src/leapflow/learning/effectiveness.py b/src/leapflow/learning/effectiveness.py index 5b5f77fe..5fb2b363 100644 --- a/src/leapflow/learning/effectiveness.py +++ b/src/leapflow/learning/effectiveness.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Learning effectiveness evaluation — quantifies whether the learning loop is actually learning. Core question: "Is the system getting better over time, or just accumulating noise?" diff --git a/src/leapflow/learning/event_consumer.py b/src/leapflow/learning/event_consumer.py index bcda52d5..7fedbac2 100644 --- a/src/leapflow/learning/event_consumer.py +++ b/src/leapflow/learning/event_consumer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Event consumer protocol — defines the interface for consuming events from EventBus. EventConsumers are registered with EventBus and receive batched events diff --git a/src/leapflow/learning/feedback.py b/src/leapflow/learning/feedback.py index d7f6eb7b..fb3ccd85 100644 --- a/src/leapflow/learning/feedback.py +++ b/src/leapflow/learning/feedback.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Feedback loop — detect re-executions of stored skills and auto-improve. When the active learning observer detects a candidate matching an existing skill diff --git a/src/leapflow/learning/learnability.py b/src/leapflow/learning/learnability.py index d697dd26..f223c7ec 100644 --- a/src/leapflow/learning/learnability.py +++ b/src/leapflow/learning/learnability.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skill learnability assessment — decides if a recorded trajectory is worth distilling. Architecture: Three-tier progressive assessment (L1 Rules → L2 VLM → L3 LLM). diff --git a/src/leapflow/learning/outcome_governance_feed.py b/src/leapflow/learning/outcome_governance_feed.py index c88744fe..33aa1c38 100644 --- a/src/leapflow/learning/outcome_governance_feed.py +++ b/src/leapflow/learning/outcome_governance_feed.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Feed execution outcomes into lifecycle governance without touching the hot path. Trust already accrues in production: ``TurnUsageTracker.record_tool_call`` forwards diff --git a/src/leapflow/learning/pattern_miner.py b/src/leapflow/learning/pattern_miner.py index 2af694ba..f0bfcc8f 100644 --- a/src/leapflow/learning/pattern_miner.py +++ b/src/leapflow/learning/pattern_miner.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Pattern miner — discovers recurring operation patterns from event history. LLM-Native design: uses simple frequency statistics to identify candidate diff --git a/src/leapflow/learning/plugin_advisor.py b/src/leapflow/learning/plugin_advisor.py index 01376c5d..dc3db43c 100644 --- a/src/leapflow/learning/plugin_advisor.py +++ b/src/leapflow/learning/plugin_advisor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Stateless scoring engine that produces plugin recommendations. Computed on-demand (when plugin_status is queried), not proactively. diff --git a/src/leapflow/learning/plugin_behavior_tests.py b/src/leapflow/learning/plugin_behavior_tests.py index 6c2e1c40..da9ec9b7 100644 --- a/src/leapflow/learning/plugin_behavior_tests.py +++ b/src/leapflow/learning/plugin_behavior_tests.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Behavior test execution for generated/profile plugins.""" from __future__ import annotations diff --git a/src/leapflow/learning/plugin_generator.py b/src/leapflow/learning/plugin_generator.py index 720f1a8c..4d5512af 100644 --- a/src/leapflow/learning/plugin_generator.py +++ b/src/leapflow/learning/plugin_generator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LLM-driven plugin code generation and validation. The capstone of LeapFlow's self-evolution: the Agent can propose a new plugin, @@ -54,6 +55,11 @@ class PluginGenerationRequest: plugin_id: str description: str # natural-language description of what the plugin should do plugin_type: str = "tool" # "tool" | "active_signal_source" + #: Declared capability names the generated plugin must provide, in the same dotted + #: vocabulary existing tools use. Without this a generated plugin declares nothing, + #: and ``DeclaredMatchScorer`` excludes it from every resolution -- so the framework + #: would build a capability it can then never select. + provides_capabilities: tuple[str, ...] = () class PluginValidator: @@ -328,6 +334,11 @@ def build_generation_prompt(self, request: PluginGenerationRequest) -> str: The prompt includes the ToolPlugin Protocol contract and an example, so the LLM generates conformant code. """ + # Rendered as a Python literal so the model can copy it verbatim. An empty + # request yields ``()``, which is honest: the caller declared no capability, so + # the prompt must not invent one. Callers that resolve a capability gap always + # have the name and are expected to pass it. + capabilities_literal = repr(tuple(request.provides_capabilities)) return f"""Generate a Python ToolPlugin for LeapFlow. Plugin ID: {request.plugin_id} @@ -347,12 +358,17 @@ def build_generation_prompt(self, request: PluginGenerationRequest) -> str: 6. Import from: from leapflow.plugins.protocol import ToolMetadata, ToolPlugin 7. NO dangerous operations (no eval/exec/os.system/file deletion at import time) 8. All handlers are async functions taking **kwargs and returning a dict -9. On success, every handler MUST report what it observably did in an "effect" key, - phrased in the same terms as the requirement above (e.g. - {{"ok": True, "effect": "the reply was delivered to the thread"}}). This is how the +9. On success, every handler MUST report what it observably did in an "observed_effect" + key, phrased in the same terms as the requirement above (e.g. + {{"ok": True, "observed_effect": "the reply was delivered to the thread"}}). This is how the framework confirms the capability actually worked rather than merely returned; a handler that omits it can never be verified, only refuted. Describe the observed outcome, never restate the intent. +10. Every ToolMetadata MUST set provides_capabilities to exactly this tuple: + {capabilities_literal} + These are the declared capability names the framework resolves against. A tool that + omits them is excluded from every capability resolution, so the plugin would be + installed and then never selected. Do not invent additional names. Example structure: ```python @@ -369,9 +385,9 @@ def dependencies(self) -> list[str]: return [] def bind_runtime(self, **deps: Any) -> None: pass @property def tools(self) -> list[ToolMetadata]: - return [ToolMetadata(name="...", description="...", parameters_schema={{"type":"object","properties":{{}}}}, handler=self._handler, x_leapflow={{"category":"custom","risk_level":"read_only"}})] + return [ToolMetadata(name="...", description="...", parameters_schema={{"type":"object","properties":{{}}}}, handler=self._handler, x_leapflow={{"category":"custom","risk_level":"read_only"}}, provides_capabilities={capabilities_literal})] async def _handler(self, **kwargs: Any) -> dict: - return {{"ok": True, "effect": ""}} + return {{"ok": True, "observed_effect": ""}} plugin = MyPlugin() ``` diff --git a/src/leapflow/learning/plugin_stats.py b/src/leapflow/learning/plugin_stats.py index 7b2ffd8d..3cc5a301 100644 --- a/src/leapflow/learning/plugin_stats.py +++ b/src/leapflow/learning/plugin_stats.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Per-plugin usage statistics accumulator. Receives forwarded (tool_name, ok, duration_ms) from TurnUsageTracker diff --git a/src/leapflow/learning/plugin_stats_store.py b/src/leapflow/learning/plugin_stats_store.py index 16106ffe..eae796a7 100644 --- a/src/leapflow/learning/plugin_stats_store.py +++ b/src/leapflow/learning/plugin_stats_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DuckDB persistence for plugin trust and usage statistics. Provides save/load for PluginTrustLedger and PluginUsageTracker state across diff --git a/src/leapflow/learning/plugin_trust.py b/src/leapflow/learning/plugin_trust.py index 7c7c2714..f7082de6 100644 --- a/src/leapflow/learning/plugin_trust.py +++ b/src/leapflow/learning/plugin_trust.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Progressive trust ledger for plugins. Trust is earned through consistent successful execution (not human approval). diff --git a/src/leapflow/learning/similarity.py b/src/leapflow/learning/similarity.py index 87864c80..c7565594 100644 --- a/src/leapflow/learning/similarity.py +++ b/src/leapflow/learning/similarity.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skill similarity scoring — heuristic fast-filter and optional LLM refinement. Two-phase architecture: diff --git a/src/leapflow/learning/stream_progress.py b/src/leapflow/learning/stream_progress.py index 7c0b164b..ff13ca5c 100644 --- a/src/leapflow/learning/stream_progress.py +++ b/src/leapflow/learning/stream_progress.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Backward-compatible re-export — canonical location is leapflow.utils.stream_progress.""" from leapflow.utils.stream_progress import StreamProgressWriter diff --git a/src/leapflow/learning/world_model_driver.py b/src/leapflow/learning/world_model_driver.py index 8d293a75..7e3002d4 100644 --- a/src/leapflow/learning/world_model_driver.py +++ b/src/leapflow/learning/world_model_driver.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """The world model as the first driver of capability self-evolution. ``TrajectoryGrader.grade_and_propose`` can emit an :class:`EvolutionIntent`, and @@ -51,8 +52,15 @@ class CapabilityGapTeacher(Protocol): substituted in tests and experiments. """ - async def grade_and_propose(self, trajectory: list[dict], goal: str = "") -> Any: - """Return an object exposing ``grades`` and ``intents``.""" + async def grade_and_propose( + self, trajectory: list[dict], goal: str = "", **kwargs: Any + ) -> Any: + """Return an object exposing ``grades`` and ``intents``. + + ``**kwargs`` keeps this structural contract open: the driver passes + ``degraded_capabilities`` when it has any, and a teacher that predates that + context stays conformant by ignoring it. + """ ... @@ -84,9 +92,21 @@ class WorldModelDriveResult: """ grades: tuple[Any, ...] = () + #: Everything the teacher concluded, across all four actions. ``intents`` below is + #: the ``acquire`` subset, so the cheap verdicts stay visible instead of being + #: dropped for not writing code -- three of the four change nothing except what the + #: acting agent knows, which is the point of asking. + verdicts: tuple[Any, ...] = () + #: Capabilities whose knowledge was written to the distilled store this session. + #: The C1 channel's receipt: a session that adapted purely by teaching the next one + #: something has this non-empty and everything else empty. + distilled: tuple[str, ...] = () intents: tuple[EvolutionIntent, ...] = () admitted_observation_ids: tuple[str, ...] = () requirements: tuple[CapabilityRequirement, ...] = field(default_factory=tuple) + #: Proposals queued for governed acquisition. Empty when no sink is installed, + #: which is the default: an intent then reaches a requirement and stops there. + queued_proposal_ids: tuple[str, ...] = () @property def proposed(self) -> int: @@ -101,10 +121,17 @@ def to_dict(self) -> dict[str, Any]: "graded_actions": len(self.grades), "proposed": self.proposed, "admitted": self.admitted, + "queued": len(self.queued_proposal_ids), "capabilities": sorted({r.capability for r in self.requirements}), + # Counted per action so a session that adapted purely by distilling + # knowledge is distinguishable from one that did nothing. + "distilled": list(self.distilled), + "by_action": { + action: sum(1 for v in self.verdicts if getattr(v, "action", "") == action) + for action in ("absorb", "rebind", "acquire", "escalate") + }, } - class WorldModelEvolutionDriver: """Turn hindsight capability hypotheses into governed requirements.""" @@ -115,11 +142,37 @@ def __init__( intake: EvidenceIntake, risk_ceiling: RiskLevel = MODEL_AUTHORED_RISK_CEILING, source: str = "world_model", + degraded_capabilities: Any = None, + proposal_sink: Any = None, + knowledge_store: Any = None, + alternatives_for: Any = None, ) -> None: self._teacher = teacher self._intake = intake self._risk_ceiling = risk_ceiling self._source = source + # Facts the teacher needs in order to adjudicate a *replacement*: which + # capabilities have a provider that keeps failing while still in service. + # Injected as a callable so the driver does not bind to a store, and so a + # deployment without governance wiring simply grades without them. + self._degraded_capabilities = degraded_capabilities + # Where an intent becomes a queued ``PluginProposal``. Without it an intent + # reaches a requirement and stops: resolution reports the capability unmet and + # nothing turns that into an acquisition. This is the last hop of the chain, + # and it stays optional because queueing proposals is a governed, opt-in + # capability rather than something grading should do by default. + self._proposal_sink = proposal_sink + # Where the cheap verdicts land. Three of the four actions change nothing except + # what the acting agent knows, so without this they would be graded, traced, and + # then thrown away -- the teacher would have judged correctly and the next + # session would repeat the same mistake. Optional so a deployment without the + # store still grades and still acquires. + self._knowledge_store = knowledge_store + # The other providers of a degraded capability, and whether each can run here. + # Without it the teacher must choose between ``rebind`` ("another installed + # capability covers this") and ``acquire`` ("nothing does") without being told + # which is true -- the deciding fact for both. + self._alternatives_for = alternatives_for async def drive( self, @@ -139,18 +192,46 @@ async def drive( """ if not trajectory: return WorldModelDriveResult() + degraded = self._collect_degraded() try: - verdict = await self._teacher.grade_and_propose(list(trajectory), goal) + verdict = await self._teacher.grade_and_propose( + list(trajectory), goal, degraded_capabilities=degraded + ) + except TypeError: + # A teacher that does not accept the newer context: grade without it + # rather than lose the episode's grading entirely. + try: + verdict = await self._teacher.grade_and_propose(list(trajectory), goal) + except Exception: # noqa: BLE001 - teacher is advisory + logger.debug("world_model_driver: teacher failed", exc_info=True) + return WorldModelDriveResult() except Exception: # noqa: BLE001 - teacher is advisory; never fail the session logger.debug("world_model_driver: teacher failed", exc_info=True) return WorldModelDriveResult() grades = tuple(getattr(verdict, "grades", ()) or ()) + verdicts = tuple(getattr(verdict, "verdicts", ()) or ()) + # Only ``acquire`` becomes an intent. The other three are conclusions about the + # environment, and forwarding them into the acquisition path would turn a + # recommendation to rebind into a request to write code. intents = tuple(getattr(verdict, "intents", ()) or ()) + # Distil before branching on ``intents``: a session whose every verdict was + # ``absorb`` adapted the system, and it is the *only* thing that happened. + distilled = self._distil(verdicts, environment) if not intents: - return WorldModelDriveResult(grades=grades) + # Still a real outcome: the teacher may have concluded the change is + # absorbable, which is the cheapest and most common correct answer. + return WorldModelDriveResult( + grades=grades, verdicts=verdicts, distilled=distilled + ) admitted: list[str] = [] + # The intents the gate actually accepted, kept alongside their observation ids. + # Collecting only the ids was enough to *count* admissions and not enough to + # act on them: queueing then received every intent whenever any one of them was + # admitted, so a rejected hypothesis reached the proposal queue through a side + # door -- the exact bypass the opt-in gate exists to prevent. + admitted_intents: list[EvolutionIntent] = [] for intent in intents: try: record = self._intake.observe_result( @@ -168,6 +249,7 @@ async def drive( observation_id = str(record.get("observation_id") or "") if observation_id: admitted.append(observation_id) + admitted_intents.append(intent) requirements: tuple[CapabilityRequirement, ...] = () if admitted: @@ -183,13 +265,176 @@ async def drive( ) result = WorldModelDriveResult( grades=grades, + verdicts=verdicts, + distilled=distilled, intents=intents, admitted_observation_ids=tuple(admitted), requirements=requirements, + queued_proposal_ids=( + self._queue_proposals(admitted_intents, degraded) + if admitted_intents + else () + ), ) self._trace_drive(result) return result + def _distil(self, verdicts: Any, environment: Any) -> tuple[str, ...]: + """Persist what each verdict concluded, returning the capabilities recorded. + + Contained: distillation improves the *next* session's context, so failing to + write it must not fail this one. Returns capability names rather than entries + because the caller reports counts and the entries live in the store. + """ + if self._knowledge_store is None or not verdicts: + return () + env = {} + if environment is not None and hasattr(environment, "to_dict"): + try: + env = dict(environment.to_dict()) + except Exception: # noqa: BLE001 - a fingerprint is context, not a gate + env = {} + try: + stored = self._knowledge_store.record_all(verdicts, environment=env) + return tuple(entry.capability for entry in stored) + except Exception: # noqa: BLE001 - distillation must never fail a session + logger.debug("world_model_driver: distillation failed", exc_info=True) + return () + + def _collect_degraded(self) -> tuple[Mapping[str, Any], ...]: + """Degradation facts for the teacher, filtered and environment-tagged. + + Prefers the intake's own reader when it has one, so the two rules that make + this evidence usable -- retry-owned classes excluded, environment fingerprint + attached -- are applied in one place rather than re-derived here. An explicit + provider still wins, which is what lets an experiment substitute its own view. + """ + provider = self._degraded_capabilities + if provider is None: + provider = getattr(self._intake, "degraded_capabilities", None) + if provider is None: + return () + try: + facts = tuple(provider() or ()) + except Exception: # noqa: BLE001 - missing context degrades grading, not the session + logger.debug("world_model_driver: degradation facts unavailable", exc_info=True) + return () + return self._with_alternatives(self._with_prior_verdicts(facts)) + + def _with_prior_verdicts( + self, facts: tuple[Mapping[str, Any], ...] + ) -> tuple[Mapping[str, Any], ...]: + """Attach what was concluded last time about each still-failing capability. + + This is the feedback edge, and without it the loop is open: the teacher would be + shown the same degradation every session and could only ever reach the same + conclusion, having no way to learn that its previous answer did not work. + + Deliberately stated as *fact*, not as a verdict on the verdict. Knowledge existing + while the capability still fails is evidence that the previous adaptation did not + resolve it -- not proof the judgement was wrong. The student may never have used + the knowledge, or the environment may have moved again, or this may be a different + failure. Which of those it is, is exactly what the teacher is for. + """ + if self._knowledge_store is None or not facts: + return facts + enriched: list[Mapping[str, Any]] = [] + for fact in facts: + capability = str(fact.get("capability") or "") + try: + prior = self._knowledge_store.for_capability(capability) + except Exception: # noqa: BLE001 - context, never a gate + prior = None + if prior is None: + enriched.append(fact) + continue + merged = dict(fact) + merged["prior_action"] = prior.action + merged["prior_knowledge"] = prior.knowledge + enriched.append(merged) + return tuple(enriched) + + def _with_alternatives( + self, facts: tuple[Mapping[str, Any], ...] + ) -> tuple[Mapping[str, Any], ...]: + """Attach the other providers of each degraded capability. + + Answers the question the action space is defined by. A teacher that cannot see + whether an alternative exists is guessing between rebind and acquire, and the + measured behaviour was exactly that. + """ + if self._alternatives_for is None or not facts: + return facts + enriched: list[Mapping[str, Any]] = [] + for fact in facts: + try: + rows = tuple( + self._alternatives_for( + str(fact.get("capability") or ""), str(fact.get("plugin_id") or "") + ) + or () + ) + except Exception: # noqa: BLE001 - context, never a gate + logger.debug("world_model_driver: alternatives unavailable", exc_info=True) + enriched.append(fact) + continue + merged = dict(fact) + merged["alternatives"] = rows + enriched.append(merged) + return tuple(enriched) + + def _queue_proposals( + self, + admitted_intents: Sequence[EvolutionIntent], + degraded: Sequence[Mapping[str, Any]] = (), + ) -> tuple[str, ...]: + """Turn *admitted* intents into queued proposals, if a sink is installed. + + Takes only the intents the evidence gate accepted, never the full set: an + intent the operator has not opted into must not become a queued acquisition by + a side door. The proposal itself mutates nothing -- generation and installation + remain separately approval-gated -- so queueing is the last *observation-only* + step. + + An intent whose capability appears in ``degraded`` is queued as a *rival* to the + named incumbent rather than as a gap fill. That is a factual lookup against the + degradation record, not a reading of the hypothesis: the record exists precisely + because a provider is installed and failing. Without the distinction the rival + would be named after the capability alone, collide with the incumbent's own + generated name, and never be able to coexist with the thing it competes against. + + Each proposal is built with the clamped risk ceiling, so a model cannot widen + the risk cap of what it is asking to have built. + """ + if self._proposal_sink is None or not admitted_intents: + return () + incumbents = { + str(item.get("capability") or ""): str(item.get("plugin_id") or "") + for item in degraded or () + if item.get("capability") + } + queued: list[str] = [] + try: + from leapflow.learning.capability_gap_detector import CapabilityGapDetector + + detector = CapabilityGapDetector() + except Exception: # noqa: BLE001 + logger.debug("world_model_driver: detector unavailable", exc_info=True) + return () + for intent in admitted_intents: + try: + proposal = detector.proposal_from_evolution_intent( + intent, + risk_ceiling=self._risk_ceiling, + incumbent=incumbents.get(str(getattr(intent, "capability", "")), ""), + ) + identifier = self._proposal_sink(proposal) + except Exception: # noqa: BLE001 - one bad intent must not stop the rest + logger.debug("world_model_driver: proposal not queued", exc_info=True) + continue + queued.append(str(identifier or getattr(proposal, "proposal_id", ""))) + return tuple(q for q in queued if q) + def _trace_drive(self, result: WorldModelDriveResult) -> None: """Emit what the teacher concluded, admitted or not. @@ -221,16 +466,22 @@ def _trace_drive(self, result: WorldModelDriveResult) -> None: ), }, summary=( - f"teacher proposed {len(intents)}, admitted {len(admitted)}" - if intents + f"teacher returned {len(result.verdicts)} verdict(s); " + f"{len(intents)} acquire, admitted {len(admitted)}" + if result.verdicts else "teacher proposed nothing" ), detail={ # The model's own hypothesis, rationale, expected effect and # confidence -- the only structured answer to "why should this # evolve" that exists anywhere. + "verdicts": [ + dict(v.to_dict()) if hasattr(v, "to_dict") else {} + for v in result.verdicts + ], "intents": [self._intent_detail(i) for i in intents], "admitted_observation_ids": list(admitted), + "queued_proposal_ids": list(result.queued_proposal_ids), "graded": len(result.grades), "requirements": len(result.requirements), "not_admitted_reason": ( diff --git a/src/leapflow/llm/__init__.py b/src/leapflow/llm/__init__.py index 22fae832..9b072f33 100644 --- a/src/leapflow/llm/__init__.py +++ b/src/leapflow/llm/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LLM providers and message utilities.""" from leapflow.llm.base import LLMChatResponse, LLMProvider diff --git a/src/leapflow/llm/_builtin_plugins.py b/src/leapflow/llm/_builtin_plugins.py index 9bbc52aa..70539680 100644 --- a/src/leapflow/llm/_builtin_plugins.py +++ b/src/leapflow/llm/_builtin_plugins.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Built-in LLM provider plugins. Contains plugin wrappers for providers that ship with LeapFlow. diff --git a/src/leapflow/llm/base.py b/src/leapflow/llm/base.py index d6c97192..372ab300 100644 --- a/src/leapflow/llm/base.py +++ b/src/leapflow/llm/base.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Abstract LLM interface.""" from __future__ import annotations diff --git a/src/leapflow/llm/message_builder.py b/src/leapflow/llm/message_builder.py index 609d0081..8f29be68 100644 --- a/src/leapflow/llm/message_builder.py +++ b/src/leapflow/llm/message_builder.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Helpers for multimodal OpenAI-compatible chat messages.""" from __future__ import annotations diff --git a/src/leapflow/llm/model_capabilities.py b/src/leapflow/llm/model_capabilities.py index 53705e8c..07bdb902 100644 --- a/src/leapflow/llm/model_capabilities.py +++ b/src/leapflow/llm/model_capabilities.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Model capability registry — dynamic context length, feature flags per model. Provides a single source of truth for model capabilities that the engine, diff --git a/src/leapflow/llm/openai_provider.py b/src/leapflow/llm/openai_provider.py index c943e659..0392789b 100644 --- a/src/leapflow/llm/openai_provider.py +++ b/src/leapflow/llm/openai_provider.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """OpenAI-compatible chat client with provider profiles, retries, and streaming.""" from __future__ import annotations diff --git a/src/leapflow/llm/provider_chain.py b/src/leapflow/llm/provider_chain.py index 4a5e71e9..f2f3f839 100644 --- a/src/leapflow/llm/provider_chain.py +++ b/src/leapflow/llm/provider_chain.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Multi-provider LLM chain — failover, credential rotation, auxiliary client. Architecture (Protocol-first, inspired by hermes credential_pool + transports): diff --git a/src/leapflow/llm/provider_registry.py b/src/leapflow/llm/provider_registry.py index ca7f2eac..d352d3ed 100644 --- a/src/leapflow/llm/provider_registry.py +++ b/src/leapflow/llm/provider_registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LLM Provider Plugin Registry. Provides discovery, registration, and lifecycle management for LLM providers. diff --git a/src/leapflow/llm/scoped_provider_registry.py b/src/leapflow/llm/scoped_provider_registry.py index b318f022..97de8acf 100644 --- a/src/leapflow/llm/scoped_provider_registry.py +++ b/src/leapflow/llm/scoped_provider_registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scoped lifecycle wrapper for LLMProviderRegistry. Leverages the existing unregister() method for cleanup, and mirrors the diff --git a/src/leapflow/logging_setup.py b/src/leapflow/logging_setup.py index b2cbc6da..93390cb0 100644 --- a/src/leapflow/logging_setup.py +++ b/src/leapflow/logging_setup.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Centralized process logging setup — the single owner of log configuration. Every LeapFlow process surface initializes logging through this module so that diff --git a/src/leapflow/memory/__init__.py b/src/leapflow/memory/__init__.py index e1301404..0c783ec0 100644 --- a/src/leapflow/memory/__init__.py +++ b/src/leapflow/memory/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapFlow Memory Subsystem — Provider-based architecture.""" import math diff --git a/src/leapflow/memory/manager.py b/src/leapflow/memory/manager.py index 9a7129d0..453fc1b9 100644 --- a/src/leapflow/memory/manager.py +++ b/src/leapflow/memory/manager.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Memory manager — unified orchestrator for all memory providers. Routes inserts to appropriate providers, aggregates search results, diff --git a/src/leapflow/memory/protocol.py b/src/leapflow/memory/protocol.py index 7c8af4e7..1d9049ff 100644 --- a/src/leapflow/memory/protocol.py +++ b/src/leapflow/memory/protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Memory subsystem protocol definitions. Defines the universal interface that all memory providers must implement, diff --git a/src/leapflow/memory/providers/__init__.py b/src/leapflow/memory/providers/__init__.py index b08661bc..d04021ab 100644 --- a/src/leapflow/memory/providers/__init__.py +++ b/src/leapflow/memory/providers/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Memory provider implementations.""" from leapflow.memory.providers.working import WorkingMemoryProvider diff --git a/src/leapflow/memory/providers/episodic.py b/src/leapflow/memory/providers/episodic.py index 969f97e2..bc6ea629 100644 --- a/src/leapflow/memory/providers/episodic.py +++ b/src/leapflow/memory/providers/episodic.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Episodic memory provider — TTL-based buffer with decay-weighted retrieval. Handles transient observations, events, and actions. Entries decay over time diff --git a/src/leapflow/memory/providers/evolution.py b/src/leapflow/memory/providers/evolution.py index 4d29341c..39189415 100644 --- a/src/leapflow/memory/providers/evolution.py +++ b/src/leapflow/memory/providers/evolution.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Evolution memory provider — Ring 3 learning support for skill episodes. Stores and retrieves skill execution episodes (actions, outcomes, rewards) diff --git a/src/leapflow/memory/providers/narrative.py b/src/leapflow/memory/providers/narrative.py index 237c1334..f1394d96 100644 --- a/src/leapflow/memory/providers/narrative.py +++ b/src/leapflow/memory/providers/narrative.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Narrative memory provider — pure-text Markdown for LLM-readable knowledge. Implements the narrative layer of the dual memory architecture: diff --git a/src/leapflow/memory/providers/semantic.py b/src/leapflow/memory/providers/semantic.py index a053c5fc..726a5166 100644 --- a/src/leapflow/memory/providers/semantic.py +++ b/src/leapflow/memory/providers/semantic.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Semantic memory provider — DuckDB-backed persistent storage with domain support. Serves as the long-term knowledge store. Accepts all memory kinds as the diff --git a/src/leapflow/memory/providers/working.py b/src/leapflow/memory/providers/working.py index ab9c1c3c..7f369037 100644 --- a/src/leapflow/memory/providers/working.py +++ b/src/leapflow/memory/providers/working.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Working memory provider — ring-buffer with token budgeting. Implements MemoryProvider protocol while preserving chat-message semantics diff --git a/src/leapflow/monitor/__init__.py b/src/leapflow/monitor/__init__.py index 90d6ddc0..793213ac 100644 --- a/src/leapflow/monitor/__init__.py +++ b/src/leapflow/monitor/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Domain-neutral monitoring subsystem: Watch -> Finding contract and runtime. Public surface: diff --git a/src/leapflow/monitor/capability_adaptation_producer.py b/src/leapflow/monitor/capability_adaptation_producer.py index 86c224fe..d5263f09 100644 --- a/src/leapflow/monitor/capability_adaptation_producer.py +++ b/src/leapflow/monitor/capability_adaptation_producer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Monitor producer for adaptive capability decision visibility.""" from __future__ import annotations diff --git a/src/leapflow/monitor/event_bridge.py b/src/leapflow/monitor/event_bridge.py index a7be4919..8ee95384 100644 --- a/src/leapflow/monitor/event_bridge.py +++ b/src/leapflow/monitor/event_bridge.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Bridge between EventBus and MonitorManager event-triggered watches. Subscribes to EventBus as a callback. When a SystemEvent arrives, diff --git a/src/leapflow/monitor/evolution_producer.py b/src/leapflow/monitor/evolution_producer.py index e5b93126..b90b4d9c 100644 --- a/src/leapflow/monitor/evolution_producer.py +++ b/src/leapflow/monitor/evolution_producer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Monitor producer for framework self-evolution transparency. Domain: ``framework_evolution``. Answers two questions the existing views cannot: @@ -30,7 +31,13 @@ import logging from typing import Any, Mapping, Sequence -from leapflow.domain.evolution_trace import ABORTED, REOPENED, RESOLVED, STILL_OPEN +from leapflow.domain.evolution_trace import ( + ABORTED, + REOPENED, + RESOLVED, + STILL_OPEN, + EvolutionStage, +) from leapflow.monitor.types import Evidence, Finding, ProducerContext, Severity, SuggestedAction logger = logging.getLogger(__name__) @@ -213,13 +220,17 @@ def _fiber_transitions( def _build_payload(self, ctx: ProducerContext) -> dict[str, Any]: snapshot = self._live_registry_snapshot() - reachability = self._reachability(snapshot) rebuilt = self._episodes(ctx) # ``None`` means the history could not be rebuilt; ``()`` means there is # genuinely none. Collapsing the two would report a local defect as an # absence of data -- the same conflation the reachability rows exist to # prevent, and it would be inconsistent for this panel to commit it. episodes: tuple[Any, ...] = rebuilt or () + # Traces are read before reachability because three of its rows are decided + # by whether the cold-path sweep left a trace. Deriving them from anything + # else is how they came to be hardcoded. + traces = self._recent_traces() + reachability = self._reachability(snapshot, traces) payload: dict[str, Any] = { "observed_at": float(getattr(ctx, "now", 0.0) or 0.0), "roster": snapshot["roster"], @@ -257,10 +268,10 @@ def _build_payload(self, ctx: ProducerContext) -> dict[str, Any]: "mutation_matrix": self._mutation_matrix(episodes), "degraded": not episodes, } - traces = self._recent_traces() payload["traces"] = traces payload["trace_feed"] = self._trace_feed(traces) payload["unadmitted"] = self._unadmitted(traces) + payload["reward_bandwidth"] = self._reward_bandwidth(traces, episodes) if rebuilt is None: payload["degraded_kind"] = UNVERIFIABLE payload["degraded_reason"] = ( @@ -824,7 +835,9 @@ def _trust_and_usage() -> tuple[Any, Any]: # ── pipeline reachability ───────────────────────────────────────────── - def _reachability(self, snapshot: Mapping[str, Any]) -> list[dict[str, Any]]: + def _reachability( + self, snapshot: Mapping[str, Any], traces: Sequence[Mapping[str, Any]] + ) -> list[dict[str, Any]]: """Report the runtime evidence for each pipeline segment. Ordered as the pipeline runs. Each row carries the measurement that was @@ -847,7 +860,7 @@ def _reachability(self, snapshot: Mapping[str, Any]) -> list[dict[str, Any]]: self._segment_plan_records(), self._segment_trust(snapshot), ] - rows.extend(self._segments_awaiting_wiring()) + rows.extend(self._segments_awaiting_wiring(traces)) return rows @staticmethod @@ -1061,40 +1074,145 @@ def _segment_trust(self, snapshot: Mapping[str, Any]) -> dict[str, Any]: status = WIRED if beyond_draft or frozen else NO_EVIDENCE return self._row("trust", "Trust accrual", status, detail) - def _segments_awaiting_wiring(self) -> list[dict[str, Any]]: - """Report the segments whose capability exists but produces no evidence. + def _segments_awaiting_wiring( + self, traces: Sequence[Mapping[str, Any]] + ) -> list[dict[str, Any]]: + """Report the three cold-path governance segments from observed traces. - Each of these has a module in the tree. That is deliberately *not* treated - as evidence: the module having no caller is exactly the failure mode this - panel exists to expose, so the row reports the absence of observed output - and names what would close it. + These rows were hardcoded to ``NO_EVIDENCE`` with advice to "wire" each + capability, and they stayed that way after ``CoevolutionSweep`` wired all + three -- so the board asserted three segments were dead while they were + running. A panel whose whole purpose is to distinguish "exists" from + "runs" must not itself hardcode the answer. + + The sweep emits one trace per step *including its no-op branches*, which is + what makes the three-state distinction possible here: + + * a trace with observations -> ``wired`` + * a trace marked ``no_op`` -> ``wired``, and the segment is simply idle + * no trace at all -> ``no_evidence``: the sweep never ran """ awaiting = ( ( + "effect_verification", "effect_verification", "Effect verification (L3)", - "no EffectVerdict observed", - "Closures currently rest on declared fitness (L2). Wire " - "CapabilityEffectVerifier to verify by observed effect.", + "The cold-path sweep never ran, so no closure has been checked " + "against an observed effect.", ), ( "quarantine_feed", + "quarantine_drain", "Quarantine feed", - "no quarantine candidate observed", - "Trust demotion is live but quarantine has no feed. Wire " - "QuarantineCandidateTracker and drain on a cold path.", + "Trust demotion is live but nothing drains the quarantine queue.", ), ( + "reclamation", "reclamation", "Unselectable reclamation", - "no reclamation candidate observed", - "Wire UnselectableArtifactReaper to find artifacts no requirement can select.", + "Nothing scans for artifacts no admissible requirement can select.", ), ) - return [ - self._row(key, label, NO_EVIDENCE, evidence, next_step=next_step) - for key, label, evidence, next_step in awaiting + rows: list[dict[str, Any]] = [] + for key, trace_kind, label, absent_step in awaiting: + seen = [t for t in traces if str(t.get("kind")) == trace_kind] + if not seen: + rows.append(self._row(key, label, NO_EVIDENCE, "no sweep trace observed", + next_step=absent_step)) + continue + active = [t for t in seen if not dict(t.get("detail") or {}).get("no_op")] + evidence = ( + f"{len(active)} observed in {len(seen)} sweep(s)" + if active + else f"{len(seen)} sweep(s), nothing to act on" + ) + rows.append(self._row(key, label, WIRED, evidence)) + return rows + + # ── reward signal bandwidth ────────────────────────────────────── + + def _reward_bandwidth( + self, traces: Sequence[Mapping[str, Any]], episodes: Sequence[Any] + ) -> dict[str, Any]: + """How much of the effect signal is actually usable as feedback. + + This is the number that decides whether any learning policy is worth + building. ``EffectVerdict`` is three-valued, and its ``None`` class is not a + rounding error: it covers a requirement that declared no expected effect, a + tool that reported no observable effect, and an absent outcome. A policy that + treated abstention as failure would demote and eventually quarantine healthy + plugins for a reporting omission -- so abstention has to be *counted*, not + folded into either side. + + ``observed`` is reported separately from the rate for the same reason the + reachability rows separate "no evidence" from "unverifiable": with no + verdicts at all, an abstain rate of 0.0 would read as a healthy signal when + it actually means there is no signal. + """ + verdicts = [ + dict(t.get("detail") or {}) + for t in traces + if str(t.get("kind")) == "effect_verification" + and not dict(t.get("detail") or {}).get("no_op") ] + by_reason: dict[str, int] = {} + decided = abstained = 0 + for verdict in verdicts: + by_reason[str(verdict.get("reason") or "unknown")] = ( + by_reason.get(str(verdict.get("reason") or "unknown"), 0) + 1 + ) + if verdict.get("verified") is None: + abstained += 1 + else: + decided += 1 + total = decided + abstained + + declared, requirements = self._declared_effect_rate(episodes) + return { + "observed": bool(total), + # The negation is carried explicitly because the view's ``when`` cannot + # invert a value, and "there is no signal" is the single most important + # thing this panel has to be able to say. + "absent": not total, + "total": total, + "decided": decided, + "abstained": abstained, + # Percent strings rather than floats: these are read, not computed + # against, and a bare 0.83 in a column headed "abstain rate" invites + # being read as a count. + "abstain_rate": _percent(abstained / total) if total else "", + "usable_rate": _percent(decided / total) if total else "", + "by_reason": [ + {"label": name, "value": count} + for name, count in sorted(by_reason.items(), key=lambda kv: -kv[1]) + ], + # The upstream cause. An effect can only be verified when the + # requirement declared one, and today only world-model-authored + # requirements carry ``expected_effect`` -- so a low rate here explains a + # high abstain rate without needing to inspect a single verdict. + "declared_effects": declared, + "requirements_seen": requirements, + "declared_rate": _percent(declared / requirements) if requirements else "", + } + + @staticmethod + def _declared_effect_rate(episodes: Sequence[Any]) -> tuple[int, int]: + """Count requirements carrying an ``expected_effect``, out of those seen. + + Read from the rebuilt episodes rather than the store directly: the ledger + already parsed the decision records, and a second reader would be a second + chance to disagree with it. + """ + declared = total = 0 + for episode in episodes: + orient = episode.trace_of(EvolutionStage.ORIENT) if hasattr(episode, "trace_of") else None + for requirement in (dict(orient.detail) if orient else {}).get("requirements") or []: + if not isinstance(requirement, Mapping): + continue + total += 1 + if str(dict(requirement.get("metadata") or {}).get("expected_effect") or ""): + declared += 1 + return declared, total @staticmethod def _row( diff --git a/src/leapflow/monitor/finding_store.py b/src/leapflow/monitor/finding_store.py index 775afb16..f4876253 100644 --- a/src/leapflow/monitor/finding_store.py +++ b/src/leapflow/monitor/finding_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DuckDB-backed persistence for monitor findings. Shares the daemon's single ``leap.duckdb`` connection via ``ConnectionHolder`` diff --git a/src/leapflow/monitor/manager.py b/src/leapflow/monitor/manager.py index 943ac8b2..1c8227de 100644 --- a/src/leapflow/monitor/manager.py +++ b/src/leapflow/monitor/manager.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Watch lifecycle orchestration for the monitoring subsystem. ``MonitorManager`` wires the domain-neutral contract to the existing scheduler: diff --git a/src/leapflow/monitor/plugin_health_producer.py b/src/leapflow/monitor/plugin_health_producer.py index 52acb6c0..78167a00 100644 --- a/src/leapflow/monitor/plugin_health_producer.py +++ b/src/leapflow/monitor/plugin_health_producer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Plugin health monitoring producer. Emits Monitor Findings when plugin trust degrades or error rate spikes, diff --git a/src/leapflow/monitor/producers.py b/src/leapflow/monitor/producers.py index f41791b0..ff4518b5 100644 --- a/src/leapflow/monitor/producers.py +++ b/src/leapflow/monitor/producers.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Producer registry: resolve per-domain observation logic by ``domain`` key. The registry keeps the runtime domain-agnostic. A new scenario registers a diff --git a/src/leapflow/monitor/series_extractor.py b/src/leapflow/monitor/series_extractor.py index 5dde3159..4ca381c6 100644 --- a/src/leapflow/monitor/series_extractor.py +++ b/src/leapflow/monitor/series_extractor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Extract structured chart data from a session's captured tool outputs. Signal-driven and anti-hallucination: this reads ONLY what the session already diff --git a/src/leapflow/monitor/session_producer.py b/src/leapflow/monitor/session_producer.py index afbe0d37..0d4810e5 100644 --- a/src/leapflow/monitor/session_producer.py +++ b/src/leapflow/monitor/session_producer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Session-analysis producer: model the current conversation as a Watch. ``SessionAnalysisProducer`` reuses the generic Watch -> Finding machinery: on each diff --git a/src/leapflow/monitor/signal_metrics.py b/src/leapflow/monitor/signal_metrics.py index 7a045f07..e55a5c6f 100644 --- a/src/leapflow/monitor/signal_metrics.py +++ b/src/leapflow/monitor/signal_metrics.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Signal flow metrics collection for real-time observability. Aggregates health metrics from EventBus, EventBridge, buffers, and monitors diff --git a/src/leapflow/monitor/signal_noise.py b/src/leapflow/monitor/signal_noise.py index 9a4e6ad6..3b763ac2 100644 --- a/src/leapflow/monitor/signal_noise.py +++ b/src/leapflow/monitor/signal_noise.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Signal noise gate for monitor and LeapBoard live-stream ingestion. This gate is intentionally *signal-attribute based* rather than natural-language diff --git a/src/leapflow/monitor/signal_producer.py b/src/leapflow/monitor/signal_producer.py index bb1bedf9..ff29c1f9 100644 --- a/src/leapflow/monitor/signal_producer.py +++ b/src/leapflow/monitor/signal_producer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Lightweight signal observation producer for event-driven watches. Produces a Finding summarizing recent signal activity for the watched domain. diff --git a/src/leapflow/monitor/types.py b/src/leapflow/monitor/types.py index 9219763e..1618c92a 100644 --- a/src/leapflow/monitor/types.py +++ b/src/leapflow/monitor/types.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Domain-neutral contract for the monitoring subsystem. A ``Watch`` is a persistent, proactive monitor that periodically observes a diff --git a/src/leapflow/perception/__init__.py b/src/leapflow/perception/__init__.py index a38cad7c..70ad4767 100644 --- a/src/leapflow/perception/__init__.py +++ b/src/leapflow/perception/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Visual-First Perception Subsystem. Supports two modes: diff --git a/src/leapflow/perception/active_signal_source.py b/src/leapflow/perception/active_signal_source.py index c7cde6a3..c394db9f 100644 --- a/src/leapflow/perception/active_signal_source.py +++ b/src/leapflow/perception/active_signal_source.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Lifecycle-bearing signal source category. Unlike SignalSource (stateless transform), ActiveSignalSource subscribes to diff --git a/src/leapflow/perception/active_sources/__init__.py b/src/leapflow/perception/active_sources/__init__.py index 3cc43672..63084e7e 100644 --- a/src/leapflow/perception/active_sources/__init__.py +++ b/src/leapflow/perception/active_sources/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Built-in ActiveSignalSource implementations organized by signal domain.""" from leapflow.perception.active_sources.discord_bot import DiscordBotSignalSource diff --git a/src/leapflow/perception/active_sources/discord_bot.py b/src/leapflow/perception/active_sources/discord_bot.py index a18c97d7..982f911a 100644 --- a/src/leapflow/perception/active_sources/discord_bot.py +++ b/src/leapflow/perception/active_sources/discord_bot.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Discord Bot ActiveSignalSource. Receives Discord interaction events via HTTP webhook (Interactions Endpoint) diff --git a/src/leapflow/perception/active_sources/feishu_im.py b/src/leapflow/perception/active_sources/feishu_im.py index ddd9c8ee..21e67299 100644 --- a/src/leapflow/perception/active_sources/feishu_im.py +++ b/src/leapflow/perception/active_sources/feishu_im.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Feishu IM Bot ActiveSignalSource. Receives Feishu instant messages and converts them into InteractionSignals, diff --git a/src/leapflow/perception/active_sources/slack_bot.py b/src/leapflow/perception/active_sources/slack_bot.py index 51cc1c0e..ceb3d5eb 100644 --- a/src/leapflow/perception/active_sources/slack_bot.py +++ b/src/leapflow/perception/active_sources/slack_bot.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Slack Bot ActiveSignalSource. Receives Slack events via HTTP webhook (Events API) and converts them into diff --git a/src/leapflow/perception/active_sources/telegram_bot.py b/src/leapflow/perception/active_sources/telegram_bot.py index 4be619ad..ba53e6ad 100644 --- a/src/leapflow/perception/active_sources/telegram_bot.py +++ b/src/leapflow/perception/active_sources/telegram_bot.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Telegram Bot ActiveSignalSource. Subscribes to Telegram Bot messages via long polling and emits diff --git a/src/leapflow/perception/active_sources_builtin.py b/src/leapflow/perception/active_sources_builtin.py index 4f6c63a6..d082f593 100644 --- a/src/leapflow/perception/active_sources_builtin.py +++ b/src/leapflow/perception/active_sources_builtin.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Built-in ActiveSignalSource implementations. The FileWatchSignalSource is the community-extension exemplar: it demonstrates diff --git a/src/leapflow/perception/config.py b/src/leapflow/perception/config.py index 69a10348..366f9153 100644 --- a/src/leapflow/perception/config.py +++ b/src/leapflow/perception/config.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Perception subsystem configuration.""" from __future__ import annotations diff --git a/src/leapflow/perception/cv/__init__.py b/src/leapflow/perception/cv/__init__.py index 655e370f..ad4b04e5 100644 --- a/src/leapflow/perception/cv/__init__.py +++ b/src/leapflow/perception/cv/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Local CV algorithms (no API calls required).""" diff --git a/src/leapflow/perception/cv/optical_flow.py b/src/leapflow/perception/cv/optical_flow.py index 33b18118..33f83695 100644 --- a/src/leapflow/perception/cv/optical_flow.py +++ b/src/leapflow/perception/cv/optical_flow.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Optical flow analysis for motion pattern classification. Uses Farneback dense optical flow to distinguish: diff --git a/src/leapflow/perception/cv/phash.py b/src/leapflow/perception/cv/phash.py index b6450113..78911fbb 100644 --- a/src/leapflow/perception/cv/phash.py +++ b/src/leapflow/perception/cv/phash.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Perceptual hashing for frame change detection. Provides fast, resize-invariant image fingerprinting using DCT-based diff --git a/src/leapflow/perception/cv/scene_cut.py b/src/leapflow/perception/cv/scene_cut.py index 3e3ffe59..899f2346 100644 --- a/src/leapflow/perception/cv/scene_cut.py +++ b/src/leapflow/perception/cv/scene_cut.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scene cut detection — distinguish hard cuts from soft transitions. Uses color histogram comparison + edge structure correlation to diff --git a/src/leapflow/perception/cv/text_diff.py b/src/leapflow/perception/cv/text_diff.py index 40a22d9b..3a6cf755 100644 --- a/src/leapflow/perception/cv/text_diff.py +++ b/src/leapflow/perception/cv/text_diff.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Text change tracking between frames using OCR region matching. Identifies new, removed, and modified text regions by spatial IoU diff --git a/src/leapflow/perception/cv/ui_detect.py b/src/leapflow/perception/cv/ui_detect.py index 41debc57..13966f41 100644 --- a/src/leapflow/perception/cv/ui_detect.py +++ b/src/leapflow/perception/cv/ui_detect.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """UI element detection — pluggable backend protocol. Defines the interface for detecting common UI elements (buttons, text fields, diff --git a/src/leapflow/perception/cv_plugins.py b/src/leapflow/perception/cv_plugins.py index 56937a99..1ee83594 100644 --- a/src/leapflow/perception/cv_plugins.py +++ b/src/leapflow/perception/cv_plugins.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """CV algorithm plugins — wraps existing ``perception/cv/`` algorithms as ``CVProcessor`` instances. diff --git a/src/leapflow/perception/cv_processor.py b/src/leapflow/perception/cv_processor.py index 80b86019..549a7cc6 100644 --- a/src/leapflow/perception/cv_processor.py +++ b/src/leapflow/perception/cv_processor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """CV Algorithm Plugin Protocol + Registry. Allows community-contributed computer vision algorithms to replace or augment diff --git a/src/leapflow/perception/encoding/__init__.py b/src/leapflow/perception/encoding/__init__.py index 9b901ccf..cddcbb71 100644 --- a/src/leapflow/perception/encoding/__init__.py +++ b/src/leapflow/perception/encoding/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Frame compression and encoding for VLM optimization.""" diff --git a/src/leapflow/perception/encoding/delta.py b/src/leapflow/perception/encoding/delta.py index 4a08ea06..c914ef65 100644 --- a/src/leapflow/perception/encoding/delta.py +++ b/src/leapflow/perception/encoding/delta.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Delta frame composition — optimized before/after encoding for VLM input.""" from __future__ import annotations diff --git a/src/leapflow/perception/encoding/encoder.py b/src/leapflow/perception/encoding/encoder.py index ed1271e4..41de2824 100644 --- a/src/leapflow/perception/encoding/encoder.py +++ b/src/leapflow/perception/encoding/encoder.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Adaptive resolution encoder — context-aware frame compression.""" from __future__ import annotations diff --git a/src/leapflow/perception/encoding/tiler.py b/src/leapflow/perception/encoding/tiler.py index 17447a18..b97b2873 100644 --- a/src/leapflow/perception/encoding/tiler.py +++ b/src/leapflow/perception/encoding/tiler.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Inference tiler — batch composed frame pairs into grid for VLM.""" from __future__ import annotations diff --git a/src/leapflow/perception/extraction/__init__.py b/src/leapflow/perception/extraction/__init__.py index 9a49034d..49867d5d 100644 --- a/src/leapflow/perception/extraction/__init__.py +++ b/src/leapflow/perception/extraction/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Offline VLM extraction pipeline.""" diff --git a/src/leapflow/perception/extraction/extractor.py b/src/leapflow/perception/extraction/extractor.py index 00ba6f7a..b36c3035 100644 --- a/src/leapflow/perception/extraction/extractor.py +++ b/src/leapflow/perception/extraction/extractor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Stage C: Context-Enriched VLM Extractor — action inference from frame pairs.""" from __future__ import annotations diff --git a/src/leapflow/perception/extraction/feature_extractor.py b/src/leapflow/perception/extraction/feature_extractor.py index a9fb1e31..eb9dedfa 100644 --- a/src/leapflow/perception/extraction/feature_extractor.py +++ b/src/leapflow/perception/extraction/feature_extractor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Feature extraction — pluggable OCR, UI detection, and embedding backends.""" from __future__ import annotations diff --git a/src/leapflow/perception/extraction/pipeline.py b/src/leapflow/perception/extraction/pipeline.py index 6e3ccabc..c376b990 100644 --- a/src/leapflow/perception/extraction/pipeline.py +++ b/src/leapflow/perception/extraction/pipeline.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Offline Extraction Pipeline — Stage A → B → C orchestrator.""" from __future__ import annotations diff --git a/src/leapflow/perception/extraction/preprocessor.py b/src/leapflow/perception/extraction/preprocessor.py index 7be1ae46..65e8993a 100644 --- a/src/leapflow/perception/extraction/preprocessor.py +++ b/src/leapflow/perception/extraction/preprocessor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Stage B: Semantic Preprocessing — CV features to PairContext.""" from __future__ import annotations diff --git a/src/leapflow/perception/extraction/refiner.py b/src/leapflow/perception/extraction/refiner.py index 07a59a0f..3d51a53a 100644 --- a/src/leapflow/perception/extraction/refiner.py +++ b/src/leapflow/perception/extraction/refiner.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Stage A: Keyframe Refinement — dedup, classify, pair, budget allocation.""" from __future__ import annotations diff --git a/src/leapflow/perception/extraction/router.py b/src/leapflow/perception/extraction/router.py index acb49488..2fd25ee0 100644 --- a/src/leapflow/perception/extraction/router.py +++ b/src/leapflow/perception/extraction/router.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tiered inference router — SKIP/LIGHT/STANDARD/DEEP level assignment.""" from __future__ import annotations diff --git a/src/leapflow/perception/implicit_feedback.py b/src/leapflow/perception/implicit_feedback.py index 981e51c0..8e4a586e 100644 --- a/src/leapflow/perception/implicit_feedback.py +++ b/src/leapflow/perception/implicit_feedback.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Implicit feedback signal detection — identifies user struggle signals. Detects patterns that indicate the user is "stuck" or struggling: diff --git a/src/leapflow/perception/sampling/__init__.py b/src/leapflow/perception/sampling/__init__.py index cf5c1c31..17ace644 100644 --- a/src/leapflow/perception/sampling/__init__.py +++ b/src/leapflow/perception/sampling/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Sampling subsystem — retained for potential screenshot-mode use.""" diff --git a/src/leapflow/perception/session.py b/src/leapflow/perception/session.py index 10da82df..9220796f 100644 --- a/src/leapflow/perception/session.py +++ b/src/leapflow/perception/session.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Perception Session — lifecycle management for visual perception. Session-scoped: created at learn-session start, collects interaction signals diff --git a/src/leapflow/perception/signal_source.py b/src/leapflow/perception/signal_source.py index 3fb3b788..a8804425 100644 --- a/src/leapflow/perception/signal_source.py +++ b/src/leapflow/perception/signal_source.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """SignalSource plugin protocol and registry for Perception signal extraction. A SignalSource transforms a normalized SystemEvent (event_type + payload) into diff --git a/src/leapflow/perception/signal_sources_builtin.py b/src/leapflow/perception/signal_sources_builtin.py index 0999fe1a..53930c8c 100644 --- a/src/leapflow/perception/signal_sources_builtin.py +++ b/src/leapflow/perception/signal_sources_builtin.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Built-in signal sources reproducing the original _extract_signal() branches. Each source encapsulates a single branch of the original hardcoded if-chain in diff --git a/src/leapflow/perception/signals.py b/src/leapflow/perception/signals.py index f81a895c..e7d54e53 100644 --- a/src/leapflow/perception/signals.py +++ b/src/leapflow/perception/signals.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Signal buffer — accumulates interaction signals between frame stores. Signals are lightweight temporal anchors (click coords, app switches, clipboard diff --git a/src/leapflow/perception/state_snapshot.py b/src/leapflow/perception/state_snapshot.py index 803c0d09..1bc38fbe 100644 --- a/src/leapflow/perception/state_snapshot.py +++ b/src/leapflow/perception/state_snapshot.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Multi-fidelity environment state snapshot service. Captures environment state at varying levels of detail for use by diff --git a/src/leapflow/perception/storage/__init__.py b/src/leapflow/perception/storage/__init__.py index 3ae3ddfe..d94973f9 100644 --- a/src/leapflow/perception/storage/__init__.py +++ b/src/leapflow/perception/storage/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Frame storage and multi-level caching.""" diff --git a/src/leapflow/perception/storage/deduplicator.py b/src/leapflow/perception/storage/deduplicator.py index 9191b680..582d7456 100644 --- a/src/leapflow/perception/storage/deduplicator.py +++ b/src/leapflow/perception/storage/deduplicator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Frame deduplication — online hash check and offline clustering.""" from __future__ import annotations diff --git a/src/leapflow/perception/storage/frame_store.py b/src/leapflow/perception/storage/frame_store.py index 63f63f40..984a75d6 100644 --- a/src/leapflow/perception/storage/frame_store.py +++ b/src/leapflow/perception/storage/frame_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Frame storage abstraction and local filesystem implementation. Migrated from leapflow.recording.frame_store with extended metadata diff --git a/src/leapflow/perception/storage/semantic_cache.py b/src/leapflow/perception/storage/semantic_cache.py index bfb7f915..ff21461a 100644 --- a/src/leapflow/perception/storage/semantic_cache.py +++ b/src/leapflow/perception/storage/semantic_cache.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Level 3: Semantic Cache — VLM extraction result reuse across sessions. Caches VLM action extraction results keyed by visual content similarity, diff --git a/src/leapflow/perception/types.py b/src/leapflow/perception/types.py index c7db6227..32eff38d 100644 --- a/src/leapflow/perception/types.py +++ b/src/leapflow/perception/types.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shared data types for the perception subsystem.""" from __future__ import annotations diff --git a/src/leapflow/perception/video/__init__.py b/src/leapflow/perception/video/__init__.py index 58a7ea19..627d2f20 100644 --- a/src/leapflow/perception/video/__init__.py +++ b/src/leapflow/perception/video/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Trajectory-first perception: recording, segmentation, and multi-scale VLM analysis.""" from leapflow.perception.video.analyzer import VideoAnalyzer diff --git a/src/leapflow/perception/video/analyzer.py b/src/leapflow/perception/video/analyzer.py index 04d692b9..286f1c68 100644 --- a/src/leapflow/perception/video/analyzer.py +++ b/src/leapflow/perception/video/analyzer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Multi-scale video analysis via VLM. Three-level progressive analysis: diff --git a/src/leapflow/perception/video/cache_manager.py b/src/leapflow/perception/video/cache_manager.py index 4be5017f..2963efed 100644 --- a/src/leapflow/perception/video/cache_manager.py +++ b/src/leapflow/perception/video/cache_manager.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Video cache lifecycle management.""" from __future__ import annotations diff --git a/src/leapflow/perception/video/prompts.py b/src/leapflow/perception/video/prompts.py index 25961534..75351743 100644 --- a/src/leapflow/perception/video/prompts.py +++ b/src/leapflow/perception/video/prompts.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Video analysis prompt strategies and VLM message builders. Follows Open/Closed Principle: extend via new implementations, diff --git a/src/leapflow/perception/video/recorder.py b/src/leapflow/perception/video/recorder.py index 68df081d..63194c6b 100644 --- a/src/leapflow/perception/video/recorder.py +++ b/src/leapflow/perception/video/recorder.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Trajectory recording lifecycle manager. Wraps CuaDriver's trajectory recording (start_recording/stop_recording) diff --git a/src/leapflow/perception/video/segmenter.py b/src/leapflow/perception/video/segmenter.py index af239884..487e086b 100644 --- a/src/leapflow/perception/video/segmenter.py +++ b/src/leapflow/perception/video/segmenter.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Video segment splitter for multi-scale VLM analysis. Splits recorded video segments into semantically coherent analysis diff --git a/src/leapflow/perception/video/timeline.py b/src/leapflow/perception/video/timeline.py index 2a3b28b0..64983102 100644 --- a/src/leapflow/perception/video/timeline.py +++ b/src/leapflow/perception/video/timeline.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Event signal timeline for video-mode recording. Collects lightweight event markers during recording. The compressed diff --git a/src/leapflow/platform/__init__.py b/src/leapflow/platform/__init__.py index 531d382f..287fa99a 100644 --- a/src/leapflow/platform/__init__.py +++ b/src/leapflow/platform/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unified platform abstraction layer — RPC, event normalization, and host adapters.""" from leapflow.platform.protocol import ( diff --git a/src/leapflow/platform/adapters/__init__.py b/src/leapflow/platform/adapters/__init__.py index d85247d2..76e37283 100644 --- a/src/leapflow/platform/adapters/__init__.py +++ b/src/leapflow/platform/adapters/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Platform-specific adapters implementing perception and execution ports.""" diff --git a/src/leapflow/platform/adapters/darwin.py b/src/leapflow/platform/adapters/darwin.py index 0803978a..ebacaaa2 100644 --- a/src/leapflow/platform/adapters/darwin.py +++ b/src/leapflow/platform/adapters/darwin.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """macOS adapter — maps VSI ports to HostRpc calls targeting CuaDriver.""" from __future__ import annotations diff --git a/src/leapflow/platform/adapters/mock.py b/src/leapflow/platform/adapters/mock.py index 434bb3ca..5f1ff931 100644 --- a/src/leapflow/platform/adapters/mock.py +++ b/src/leapflow/platform/adapters/mock.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Mock adapter for testing without a native host process.""" from __future__ import annotations diff --git a/src/leapflow/platform/capabilities.py b/src/leapflow/platform/capabilities.py index eb6002d2..0519c919 100644 --- a/src/leapflow/platform/capabilities.py +++ b/src/leapflow/platform/capabilities.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Runtime environment capability detection and caching. Probes the platform connection, platform manifest, and permission state diff --git a/src/leapflow/platform/client.py b/src/leapflow/platform/client.py index 3c500355..dbf905f5 100644 --- a/src/leapflow/platform/client.py +++ b/src/leapflow/platform/client.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Platform client utilities.""" from __future__ import annotations diff --git a/src/leapflow/platform/cua_client.py b/src/leapflow/platform/cua_client.py index 3ab43265..869ed00b 100644 --- a/src/leapflow/platform/cua_client.py +++ b/src/leapflow/platform/cua_client.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """CuaDriverClient — MCP stdio bridge to cua-driver for unified OS execution. Implements the HostRpc Protocol by mapping LeapFlow's Methods constants to diff --git a/src/leapflow/platform/event_bus.py b/src/leapflow/platform/event_bus.py index e93f91d2..2545559f 100644 --- a/src/leapflow/platform/event_bus.py +++ b/src/leapflow/platform/event_bus.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Event bus: handles platform events and routes them to memory/skills. Pipeline: CuaDriver/Observer → EventBus → Normalizer → EpisodicMemory → (promotion) → SemanticMemory diff --git a/src/leapflow/platform/facade.py b/src/leapflow/platform/facade.py index 2894f713..5fe871e4 100644 --- a/src/leapflow/platform/facade.py +++ b/src/leapflow/platform/facade.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """VSI Facade — single entry point exposing platform-agnostic ports to the engine.""" from __future__ import annotations diff --git a/src/leapflow/platform/mcp_manager.py b/src/leapflow/platform/mcp_manager.py index 5cdeef50..45aba1d9 100644 --- a/src/leapflow/platform/mcp_manager.py +++ b/src/leapflow/platform/mcp_manager.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """MCP Server Manager — generalized MCP client for arbitrary servers. Design (inspired by hermes tools/mcp_tool.py, generalized from CuaDriverClient): diff --git a/src/leapflow/platform/mock.py b/src/leapflow/platform/mock.py index 9605232b..c817e26f 100644 --- a/src/leapflow/platform/mock.py +++ b/src/leapflow/platform/mock.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """In-process mock platform backend with event simulation for testing.""" from __future__ import annotations diff --git a/src/leapflow/platform/normalizer.py b/src/leapflow/platform/normalizer.py index 673bc68a..df89f14f 100644 --- a/src/leapflow/platform/normalizer.py +++ b/src/leapflow/platform/normalizer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Event normalization engine — transforms platform-specific raw events into SystemEvent.""" from __future__ import annotations diff --git a/src/leapflow/platform/observers/__init__.py b/src/leapflow/platform/observers/__init__.py index a874afc3..af8fb5ad 100644 --- a/src/leapflow/platform/observers/__init__.py +++ b/src/leapflow/platform/observers/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cross-platform event observers for passive signal collection. Each observer implements the Observer Protocol and publishes events diff --git a/src/leapflow/platform/observers/app_focus.py b/src/leapflow/platform/observers/app_focus.py index 5851b1bb..1fbda4fb 100644 --- a/src/leapflow/platform/observers/app_focus.py +++ b/src/leapflow/platform/observers/app_focus.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Application focus change observer (cross-platform). Detects when the user switches between foreground applications. diff --git a/src/leapflow/platform/observers/clipboard.py b/src/leapflow/platform/observers/clipboard.py index e116cf48..aa920ae5 100644 --- a/src/leapflow/platform/observers/clipboard.py +++ b/src/leapflow/platform/observers/clipboard.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Clipboard change observer (cross-platform). Monitors clipboard content via polling and emits CLIPBOARD_CHANGE events diff --git a/src/leapflow/platform/observers/daemon.py b/src/leapflow/platform/observers/daemon.py index 3bb3ee19..32f050d9 100644 --- a/src/leapflow/platform/observers/daemon.py +++ b/src/leapflow/platform/observers/daemon.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Observation daemon — manages all observer lifecycles for 24/7 resident observation. ObservationDaemon is the single entry point for starting/stopping the entire diff --git a/src/leapflow/platform/observers/fs_watcher.py b/src/leapflow/platform/observers/fs_watcher.py index 5be0a603..71e77aad 100644 --- a/src/leapflow/platform/observers/fs_watcher.py +++ b/src/leapflow/platform/observers/fs_watcher.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """File system change observer using watchdog (cross-platform). Backends: diff --git a/src/leapflow/platform/observers/input_tap.py b/src/leapflow/platform/observers/input_tap.py index 8eb95d06..cac40a4a 100644 --- a/src/leapflow/platform/observers/input_tap.py +++ b/src/leapflow/platform/observers/input_tap.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Keyboard and mouse input event observer (cross-platform). Captures low-level input events and publishes UI_ACTION events. diff --git a/src/leapflow/platform/protocol.py b/src/leapflow/platform/protocol.py index 03eaa292..baf2e858 100644 --- a/src/leapflow/platform/protocol.py +++ b/src/leapflow/platform/protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """MsgPack-RPC framing, method constants, and event bus protocol.""" from __future__ import annotations diff --git a/src/leapflow/platform/relevance.py b/src/leapflow/platform/relevance.py index 53acbc5c..e94091a7 100644 --- a/src/leapflow/platform/relevance.py +++ b/src/leapflow/platform/relevance.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Semantic relevance scoring with platform-aware weight profiles.""" from __future__ import annotations diff --git a/src/leapflow/platform/reorder_buffer.py b/src/leapflow/platform/reorder_buffer.py index 60904d00..b9e10ab8 100644 --- a/src/leapflow/platform/reorder_buffer.py +++ b/src/leapflow/platform/reorder_buffer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Async event reorder buffer for correcting cross-source arrival inversions. Events produced by different observer threads (CGEvent tap, app focus, diff --git a/src/leapflow/plugins/__init__.py b/src/leapflow/plugins/__init__.py index 6867e598..81774bcf 100644 --- a/src/leapflow/plugins/__init__.py +++ b/src/leapflow/plugins/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Plugin subsystem — contracts, discovery, lifecycle, and the live registry. This package owns everything about *extending* LeapFlow: the ``ToolPlugin`` diff --git a/src/leapflow/plugins/_builtin_policies.py b/src/leapflow/plugins/_builtin_policies.py new file mode 100644 index 00000000..4eabc54c --- /dev/null +++ b/src/leapflow/plugins/_builtin_policies.py @@ -0,0 +1,133 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Built-in selection policies. One module registering many, like the LLM providers. + +``GreedyPolicy`` is the shipped default and reproduces the resolver's previous +selection exactly: highest weighted score, ties broken by a stable sort on +``(plugin_id, tool_name)``, with an optional arbiter consulted only among ties. That +equivalence is the point -- introducing the seam must change no behaviour, so the +first policy is measured against what it replaced rather than described as similar. + +The arbiter moved here from the resolver deliberately. A tie-break is a property of +*greedy* scoring: under Thompson sampling two candidates never tie, because each draw +is continuous. Leaving it in the resolver would have made every future policy inherit +a hook that means nothing to it. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Mapping, Sequence + +from leapflow.plugins.selection_policy import ( + PolicyDeps, + RewardSignal, + SelectionOutcome, + SelectionPolicy, +) + +if TYPE_CHECKING: # pragma: no cover - typing only + from leapflow.domain.capability_requirement import CapabilityRequirement + from leapflow.plugins.capability_resolver import CandidateScore, ResolverContext + from leapflow.plugins.selection_policy_registry import SelectionPolicyRegistry + +logger = logging.getLogger(__name__) + + +class GreedyPolicy: + """Always the highest-scoring admissible candidate. No exploration, no state. + + The honest baseline, and also the reason a learning policy is worth building: a + candidate with no usage samples scores zero on reliability and, at ``DRAFT``, zero + on trust -- so against an incumbent with any history it loses deterministically. + Trust is earned by being selected, and selection requires trust, which closes a + loop that no amount of tuning the weights opens. This policy cannot escape that; + naming it here is what makes the next one a decision rather than a preference. + """ + + policy_id = "greedy" + + def __init__(self, arbiter: Any = None) -> None: + self._arbiter = arbiter + + def select( + self, + requirement: CapabilityRequirement, + eligible: Sequence[CandidateScore], + context: ResolverContext, + ) -> SelectionOutcome: + top_score = max(c.total_score for c in eligible) + tied = tuple(c for c in eligible if c.total_score == top_score) + selected = _stable_first(tied) + arbitration_used = False + if len(tied) > 1 and self._arbiter is not None: + try: + chosen = self._arbiter.choose(requirement, tied, context) + except Exception: # noqa: BLE001 - an advisory hook must not fail selection + logger.debug("greedy: arbiter failed", exc_info=True) + chosen = None + picked = next((c for c in tied if c.candidate.tool_name == chosen), None) + if picked is not None: + selected = picked + arbitration_used = True + return SelectionOutcome( + selected=selected, + policy_id=self.policy_id, + reason=( + f"highest score {top_score:.3f}" + + (f" among {len(tied)} tied" if len(tied) > 1 else "") + + (" (arbitrated)" if arbitration_used else "") + ), + # Greedy is the argmax by construction, so it never explores. Stated + # rather than left to default: the board reads this field to tell + # deliberate exploration from a scoring bug. + explored=False, + arbitration_used=arbitration_used, + ) + + def observe( + self, + requirement: CapabilityRequirement, + chosen_tool: str, + reward: RewardSignal, + ) -> None: + """Greedy learns nothing. Accepting the call keeps the seam uniform. + + A no-op rather than an omission: the feedback edge is wired for every policy, + so adding a learning one is a new file and a config value rather than a change + to the call sites that report outcomes. + """ + return None + + +def _stable_first(scores: Sequence[CandidateScore]) -> CandidateScore: + """Deterministic tie-break, identical to the resolver's previous rule.""" + return sorted(scores, key=lambda s: (s.candidate.plugin_id, s.candidate.tool_name))[0] + + +class GreedyPolicyPlugin: + """Declares :class:`GreedyPolicy` to the registry.""" + + @property + def policy_id(self) -> str: + return GreedyPolicy.policy_id + + @property + def display_name(self) -> str: + return "Greedy (highest score)" + + def create(self, params: Mapping[str, Any], deps: PolicyDeps) -> SelectionPolicy: + # ``arbiter`` arrives through deps rather than params: it is a live object, + # not a configuration value, and config carries no object references. + return GreedyPolicy(arbiter=deps.arbiter) + + +def register_builtin_policies(registry: SelectionPolicyRegistry) -> None: + """Register every built-in policy. One call site, so the set is auditable.""" + registry.register(GreedyPolicyPlugin()) + + +__all__ = [ + "GreedyPolicy", + "GreedyPolicyPlugin", + "register_builtin_policies", +] diff --git a/src/leapflow/plugins/adaptive_loop.py b/src/leapflow/plugins/adaptive_loop.py index 59dcceda..16a07ab4 100644 --- a/src/leapflow/plugins/adaptive_loop.py +++ b/src/leapflow/plugins/adaptive_loop.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Adaptive plugin closed-loop orchestration primitives. This module is an application service above the plugin registry. It connects @@ -8,6 +9,7 @@ from __future__ import annotations +import logging import uuid from dataclasses import dataclass, field from typing import Any, Callable, Mapping, Protocol, Sequence, runtime_checkable @@ -24,6 +26,8 @@ candidates_from_registry, ) +logger = logging.getLogger(__name__) + CandidateFilter = Callable[[CapabilityCandidate], bool] @@ -214,6 +218,65 @@ def record( ) + +def live_learning_signals() -> tuple[Any, Any]: + """The live trust ledger and usage tracker, or ``(None, None)``. + + Both live on the process-global advisor, which is absent in-process and in most + tests. Absence is degradation, not failure: ``TrustScorer`` and + ``ReliabilityScorer`` then report "unavailable" and score 0, which is what they + did for every production selection before this was wired -- meaning both adaptive + signals contributed nothing and selection fell to the static scorers with an + alphabetical tie-break. + + Shared with the evolution board's ``_trust_and_usage`` rather than duplicated: + two accessors would let the board and the resolver disagree about what trust a + plugin has. + """ + try: + from leapflow.learning.plugin_advisor import get_default_advisor + + advisor = get_default_advisor() + except Exception: # noqa: BLE001 - no advisor is a degraded signal, not a failure + return None, None + if advisor is None: + return None, None + return getattr(advisor, "_trust_ledger", None), getattr(advisor, "_usage_tracker", None) + + +def _configured_policy( + trust_ledger: Any, usage_tracker: Any, settings: Any = None +) -> Any: + """Activate the configured selection policy, or fall back to the built-in default. + + This loop is the policy's owner: it is the component that selects, and the only + one holding the live services a policy is allowed to read. Activation caches the + instance process-wide so a learning policy accumulates across turns and the + cold-path sweep reports back to the *same* object. + + ``settings`` is pushed in by the caller rather than read here, because + ``get_settings()`` is a boot snapshot with no refresh path: reading it would pin + the policy to whatever configuration existed at process start and make + ``selection.policy`` a setting that reports itself as hot-reloadable and never + changes anything. + + Returns ``None`` on any failure, which leaves ``CapabilityResolver`` to construct + ``GreedyPolicy`` itself. Selection must keep working when configuration is absent + or wrong -- a misconfigured strategy is a reason to log and use the default, never + a reason to stop choosing tools. + """ + try: + from leapflow.plugins.selection_policy import PolicyDeps + from leapflow.plugins.selection_policy_registry import get_selection_policy_registry + + return get_selection_policy_registry().activate( + PolicyDeps(trust_ledger=trust_ledger, usage_tracker=usage_tracker), + settings=settings, + ) + except Exception: # noqa: BLE001 - configuration must not break tool selection + logger.debug("adaptive_loop: falling back to the default selection policy", exc_info=True) + return None + class AdaptivePluginLoop: """Resolve capability plans before and after approval-gated registry mutations.""" @@ -226,12 +289,20 @@ def __init__( resolver: CapabilityResolver | None = None, trust_ledger: Any = None, usage_tracker: Any = None, + settings: Any = None, + distilled_preferences: Any = None, ) -> None: self._registry = registry self._lifecycle_actor = lifecycle_actor - self._resolver = resolver or CapabilityResolver() self._trust_ledger = trust_ledger self._usage_tracker = usage_tracker + # Channel C2. Read per resolution rather than captured, because the teacher's + # recommendation is superseded and retracted between sessions and a snapshot would + # keep preferring a provider the knowledge behind it no longer endorses. + self._distilled_preferences = distilled_preferences + self._resolver = resolver or CapabilityResolver( + policy=_configured_policy(trust_ledger, usage_tracker, settings) + ) self._recorder = CapabilityDecisionRecorder(plan_store) def plan_next_action( @@ -315,6 +386,20 @@ async def apply_policy_decision( return result return {"ok": False, "error": f"Unsupported policy action: {action}"} + def _read_preferences(self) -> tuple[tuple[str, str], ...]: + """The teacher's rebind recommendations, or nothing. + + Contained and empty on failure: a missing preference costs a better choice, while + raising here would cost the resolution itself. + """ + if self._distilled_preferences is None: + return () + try: + return tuple(self._distilled_preferences() or ()) + except Exception: # noqa: BLE001 - a preference is evidence, never a gate + logger.debug("adaptive_loop: distilled preferences unavailable", exc_info=True) + return () + def resolve_once( self, request: AdaptiveLoopRequest, @@ -338,6 +423,10 @@ def resolve_once( environment=request.environment, trust_ledger=self._trust_ledger, usage_tracker=self._usage_tracker, + # Channel C2: the provider the teacher named in a ``rebind``. A preference + # added to a score, never an exclusion -- an inadmissible candidate still + # loses to the affordance scorer no matter what was recommended. + distilled_preferences=self._read_preferences(), ) resolutions = self._resolver.resolve_all(request.requirements, candidates, context) plan = CapabilityPlan.from_scores( diff --git a/src/leapflow/plugins/adaptive_policy.py b/src/leapflow/plugins/adaptive_policy.py index 731ff9b9..ed00711e 100644 --- a/src/leapflow/plugins/adaptive_policy.py +++ b/src/leapflow/plugins/adaptive_policy.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Policy decisions for adaptive plugin evolution. The policy is intentionally metadata-driven. It never inspects natural-language diff --git a/src/leapflow/plugins/capability_plan.py b/src/leapflow/plugins/capability_plan.py index 269df381..62aa914d 100644 --- a/src/leapflow/plugins/capability_plan.py +++ b/src/leapflow/plugins/capability_plan.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Capability orchestration plan derived from selected plugin candidates. The plan is intentionally declarative. It describes dependency order and risk diff --git a/src/leapflow/plugins/capability_resolver.py b/src/leapflow/plugins/capability_resolver.py index 5036f034..ae792930 100644 --- a/src/leapflow/plugins/capability_resolver.py +++ b/src/leapflow/plugins/capability_resolver.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Deterministic adaptive plugin capability resolution. The resolver answers: given structured requirements and the current environment, @@ -15,7 +16,9 @@ from leapflow.domain.environment_fingerprint import EnvironmentFingerprint from leapflow.learning.plugin_stats import PluginUsageTracker from leapflow.learning.plugin_trust import PluginTrustLedger, PluginTrustLevel +from leapflow.plugins._builtin_policies import GreedyPolicy from leapflow.plugins.protocol import ToolMetadata +from leapflow.plugins.selection_policy import SelectionPolicy _RISK_RANK = { "read_only": 0, @@ -107,6 +110,10 @@ class ResolverWeights: risk_cost: float = 1.0 trust: float = 1.0 reliability: float = 1.0 + #: Weight for the teacher's rebind recommendation. Deliberately below the structural + #: weights: a hindsight recommendation is evidence, and it must not outvote a + #: declaration that a candidate cannot run here. + distilled_preference: float = 0.5 @dataclass(frozen=True) @@ -117,6 +124,9 @@ class ResolverContext: trust_ledger: PluginTrustLedger | None = None usage_tracker: PluginUsageTracker | None = None weights: ResolverWeights = field(default_factory=ResolverWeights) + #: ``capability -> preferred plugin or tool name``, from the teacher's ``rebind`` + #: verdicts. Read-only evidence like everything else here: the resolver still decides. + distilled_preferences: tuple[tuple[str, str], ...] = field(default_factory=tuple) @dataclass(frozen=True) @@ -181,6 +191,11 @@ class CapabilityResolution: candidates: tuple[CandidateScore, ...] selected: CandidateScore | None = None arbitration_used: bool = False + #: Which policy chose, and whether it departed from the argmax. Recorded so a + #: decision stays auditable once selection is pluggable: an operator seeing a + #: lower-scored tool selected must be able to tell exploration from a bug. + policy_id: str = "" + explored: bool = False reason: str = "" @property @@ -193,6 +208,8 @@ def to_dict(self) -> dict[str, Any]: "selected": self.selected.to_dict() if self.selected else None, "unmet": self.unmet, "arbitration_used": self.arbitration_used, + "policy_id": self.policy_id, + "explored": self.explored, "reason": self.reason, "candidates": [c.to_dict() for c in self.candidates], } @@ -450,6 +467,50 @@ def score( ) +class DistilledPreferenceScorer: + """Prefer the provider the teacher named in a ``rebind`` verdict. + + This is channel C2, and until now the teacher's most frequent recommendation had + nowhere to land: a ``rebind`` naming ``chat_reply_v2_native`` reached the student as a + line of prose and the selection layer never heard about it. Measured on a real model, + ``rebind`` was the answer it reached for most readily -- so leaving it inert wasted the + verdict the loop produces most. + + A preference, never a gate, and weighted below the structural scorers on purpose: + + * It **adds** to a candidate's score rather than excluding its rivals, so a + recommendation cannot make an inadmissible candidate win -- affordance and frozen + exclusions still apply and still exclude. + * It cannot outvote a declaration. Hindsight is evidence about the world; a + declaration is a fact about the code, and when they disagree the code wins. + * It expires with the knowledge that produced it. The entry is retracted when the + capability recovers and superseded by the next verdict, so a stale preference stops + being read rather than having to be unlearned. + """ + + name = "distilled_preference" + + def score( + self, + requirement: CapabilityRequirement, + candidate: CapabilityCandidate, + context: ResolverContext, + ) -> ScoreComponent: + preferred = dict(context.distilled_preferences).get(requirement.capability, "") + if not preferred: + return ScoreComponent( + self.name, 0.0, context.weights.distilled_preference, "no recommendation" + ) + matched = preferred in (candidate.plugin_id, candidate.tool_name) + return ScoreComponent( + self.name, + 1.0 if matched else 0.0, + context.weights.distilled_preference, + f"teacher recommended {preferred}" + + ("" if matched else f"; this candidate is {candidate.plugin_id}"), + ) + + _DEFAULT_SCORERS: tuple[CapabilityScorer, ...] = ( DeclaredMatchScorer(), EnvironmentFitScorer(), @@ -465,10 +526,13 @@ class CapabilityResolver: def __init__( self, scorers: Sequence[CapabilityScorer] = _DEFAULT_SCORERS, - arbiter: CapabilityArbiter | None = None, + policy: SelectionPolicy | None = None, ) -> None: self._scorers = tuple(scorers) - self._arbiter = arbiter + # Greedy by default, which reproduces the selection this resolver made before + # the seam existed. Built directly rather than through the registry so a + # resolver constructed in a test needs no process-wide state. + self._policy: SelectionPolicy = policy or GreedyPolicy() def resolve_all( self, @@ -485,7 +549,14 @@ def resolve_one( candidates: Sequence[CapabilityCandidate], context: ResolverContext, ) -> CapabilityResolution: - """Score candidates and select the best eligible one.""" + """Score every candidate, then let the policy choose among the admissible ones. + + The split is the seam: scoring is per-candidate and stateless, selection sees + the whole admissible set and may carry state. Only ``eligible`` reaches the + policy -- a candidate excluded by a risk ceiling, a missing affordance or a + frozen plugin is filtered out first, so exploration can never reach a tool the + safety layer refused. + """ scored = tuple(self._score_candidate(requirement, c, context) for c in candidates) eligible = tuple(c for c in scored if c.eligible) if not eligible: @@ -495,22 +566,18 @@ def resolve_one( selected=None, reason="no eligible candidate declared the required capability and environment fit", ) - top_score = max(c.total_score for c in eligible) - tied = tuple(c for c in eligible if c.total_score == top_score) - arbitration_used = False - selected = self._stable_first(tied) - if len(tied) > 1 and self._arbiter is not None: - chosen = self._arbiter.choose(requirement, tied, context) - picked = next((c for c in tied if c.candidate.tool_name == chosen), None) - if picked is not None: - selected = picked - arbitration_used = True + outcome = self._policy.select(requirement, eligible, context) return CapabilityResolution( requirement=requirement, candidates=tuple(sorted(scored, key=self._sort_key)), - selected=selected, - arbitration_used=arbitration_used, - reason=f"selected {selected.candidate.tool_name!r} with score {selected.total_score:.3f}", + selected=outcome.selected, + arbitration_used=outcome.arbitration_used, + policy_id=outcome.policy_id, + explored=outcome.explored, + reason=( + f"selected {outcome.selected.candidate.tool_name!r} by " + f"{outcome.policy_id}: {outcome.reason}" + ), ) def _score_candidate( @@ -528,10 +595,6 @@ def _score_candidate( def _sort_key(score: CandidateScore) -> tuple[bool, float, str, str]: return (not score.eligible, -score.total_score, score.candidate.plugin_id, score.candidate.tool_name) - @staticmethod - def _stable_first(scores: Sequence[CandidateScore]) -> CandidateScore: - return sorted(scores, key=lambda s: (s.candidate.plugin_id, s.candidate.tool_name))[0] - def candidates_from_registry(registry: Any) -> tuple[CapabilityCandidate, ...]: """Build candidates from the registry's live, conflict-resolved catalog.""" diff --git a/src/leapflow/plugins/dsh/__init__.py b/src/leapflow/plugins/dsh/__init__.py index d287e9ad..176c06d3 100644 --- a/src/leapflow/plugins/dsh/__init__.py +++ b/src/leapflow/plugins/dsh/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Restricted DeepSeek Harness / Cordis plugin bridge runtime.""" from leapflow.plugins.dsh.capabilities import ( CurlGetSpec, diff --git a/src/leapflow/plugins/dsh/bundle.py b/src/leapflow/plugins/dsh/bundle.py index 3384f8e8..55297ab1 100644 --- a/src/leapflow/plugins/dsh/bundle.py +++ b/src/leapflow/plugins/dsh/bundle.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Safe copying and runtime preparation for DSH source bundles.""" from __future__ import annotations diff --git a/src/leapflow/plugins/dsh/capabilities.py b/src/leapflow/plugins/dsh/capabilities.py index df1bbe5c..71a0557a 100644 --- a/src/leapflow/plugins/dsh/capabilities.py +++ b/src/leapflow/plugins/dsh/capabilities.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Host-side typed capabilities exposed to restricted DSH workers. Foreign code never receives raw shell, filesystem, process or network access. diff --git a/src/leapflow/plugins/dsh/descriptor.py b/src/leapflow/plugins/dsh/descriptor.py index b1d5d45c..ccc3ba16 100644 --- a/src/leapflow/plugins/dsh/descriptor.py +++ b/src/leapflow/plugins/dsh/descriptor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Persistent descriptors and wrapper generation for installed DSH plugins.""" from __future__ import annotations diff --git a/src/leapflow/plugins/dsh/installer.py b/src/leapflow/plugins/dsh/installer.py index 6133c85f..05a0b04a 100644 --- a/src/leapflow/plugins/dsh/installer.py +++ b/src/leapflow/plugins/dsh/installer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Prepare and validate DSH plugin installations before registry mutation.""" from __future__ import annotations diff --git a/src/leapflow/plugins/dsh/node_host.py b/src/leapflow/plugins/dsh/node_host.py index 55276864..e5c6939f 100644 --- a/src/leapflow/plugins/dsh/node_host.py +++ b/src/leapflow/plugins/dsh/node_host.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Restricted Node subprocess host for executable DSH plugin bridges.""" from __future__ import annotations diff --git a/src/leapflow/plugins/dsh/plugin.py b/src/leapflow/plugins/dsh/plugin.py index 09ac72c9..a567cacb 100644 --- a/src/leapflow/plugins/dsh/plugin.py +++ b/src/leapflow/plugins/dsh/plugin.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Native ToolPlugin wrapper for an installed restricted DSH bundle.""" from __future__ import annotations diff --git a/src/leapflow/plugins/dsh/protocol.py b/src/leapflow/plugins/dsh/protocol.py index 5b33f572..a19feb14 100644 --- a/src/leapflow/plugins/dsh/protocol.py +++ b/src/leapflow/plugins/dsh/protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Versioned NDJSON protocol for the restricted DSH Node worker.""" from __future__ import annotations diff --git a/src/leapflow/plugins/evolution_contracts.py b/src/leapflow/plugins/evolution_contracts.py index 8bf4b34a..803e2f2a 100644 --- a/src/leapflow/plugins/evolution_contracts.py +++ b/src/leapflow/plugins/evolution_contracts.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Contracts for the capability-evolution lifecycle. ``AdaptiveEvolutionPolicy`` and ``LifecycleGovernor`` are the trust, probation and diff --git a/src/leapflow/plugins/handler_invocation.py b/src/leapflow/plugins/handler_invocation.py index 23aaa7a6..a77159d4 100644 --- a/src/leapflow/plugins/handler_invocation.py +++ b/src/leapflow/plugins/handler_invocation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Invocation adapter for ToolMetadata handlers. Tool handlers historically used two call shapes: diff --git a/src/leapflow/plugins/lifecycle_governor.py b/src/leapflow/plugins/lifecycle_governor.py index aadfc188..e870d30d 100644 --- a/src/leapflow/plugins/lifecycle_governor.py +++ b/src/leapflow/plugins/lifecycle_governor.py @@ -1,13 +1,17 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Lifecycle governance for adaptive plugin proposals.""" from __future__ import annotations +import logging from dataclasses import dataclass, field from typing import Any, Mapping from leapflow.learning.plugin_trust import PluginTrustLedger, PluginTrustLevel from leapflow.plugins.evolution_contracts import EvolutionLifecycleStore, OutcomeStore +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class LifecycleGovernanceResult: @@ -55,6 +59,7 @@ def __init__( trust_ledger: PluginTrustLedger | None = None, quarantine_after: int = 3, verified_at: PluginTrustLevel = PluginTrustLevel.VERIFIED, + degradation_sink: Any = None, ) -> None: self._proposal_queue = proposal_queue self._outcome_store = outcome_store @@ -62,6 +67,55 @@ def __init__( self._trust_ledger = trust_ledger or PluginTrustLedger() self._quarantine_after = max(1, int(quarantine_after)) self._verified_at = verified_at + # Receives the health of a plugin that remains *in service*, on every outcome: + # a non-zero failure streak is the state between healthy and quarantined, which + # had no expression before, and a zero streak retires it again. Injected rather + # than reached for, because turning this into capability-scoped evidence needs + # the registry's declarations and this class deliberately has no registry. + # Absence degrades to today's behaviour. + self._degradation_sink = degradation_sink + + def _report_health( + self, + plugin_id: str, + failure_streak: int, + trust: PluginTrustLevel, + failure_class: str = "", + ) -> None: + """Hand the still-serving plugin's health to the sink, if one is installed. + + Called for both outcomes. ``failure_streak`` carries the whole state + declaratively: non-zero means "failing while still in service" -- the state + between healthy and quarantined that had no expression before -- and zero means + the provider has recovered, so whatever degradation was recorded for it can be + retired. One signal, both directions, so the sink never has to infer recovery + from an absence of reports. + + ``failure_class`` travels with it because the streak alone cannot say what kind + of failure it was, and the answer differs entirely: a timeout is the retry + layer's business, a missing scope is the operator's, and only a semantic + mismatch is evidence about the implementation. Flattening them all into "failed + twice" asks a hindsight evaluator to adjudicate a transient. + + The streak travels as *evidence strength*, never as a gate: no threshold here + decides that a rival should be built. That judgement needs to tell a badly + written implementation from a changed environment -- both produce consecutive + failures and they want opposite actions -- which a counter cannot do and a + hindsight evaluator can. + + Never raises: governance bookkeeping must not fail the sweep that drives it. + """ + if self._degradation_sink is None: + return + try: + self._degradation_sink( + plugin_id=str(plugin_id or ""), + failure_streak=int(failure_streak), + trust_level=trust.name, + failure_class=str(failure_class or ""), + ) + except Exception: # noqa: BLE001 - reporting is advisory + logger.debug("lifecycle_governor: health not reported", exc_info=True) async def record_outcome( self, @@ -126,6 +180,17 @@ async def record_outcome( trust_state={"level": trust.name, "failure_streak": failure_streak}, test_results=[outcome], ) + if action != "quarantine": + # Reported on success as well as failure, because a health signal that only + # ever fires one way has no way back. On success the streak is 0 by + # construction, and that is the retirement signal: without it a degradation + # record stays open forever, ``unresolved()`` grows monotonically, and the + # teacher keeps being told a capability is failing long after it recovered + # -- which would drive it to propose rivals for a healthy provider. + # + # Quarantine is excluded either way: a disabled plugin is a gap, not a + # degradation, and it is no longer serving anything to recover. + self._report_health(plugin_id, failure_streak, trust, failure_class) return LifecycleGovernanceResult( action=action, plugin_id=plugin_id, diff --git a/src/leapflow/plugins/marketplace/__init__.py b/src/leapflow/plugins/marketplace/__init__.py index 2f7a48f0..5130377b 100644 --- a/src/leapflow/plugins/marketplace/__init__.py +++ b/src/leapflow/plugins/marketplace/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Plugin marketplace for discovering and installing external plugins.""" from leapflow.plugins.marketplace.client import MarketplaceClient, MarketplaceSource from leapflow.plugins.marketplace.http_source import HttpMarketplaceSource diff --git a/src/leapflow/plugins/marketplace/client.py b/src/leapflow/plugins/marketplace/client.py index aee8390a..0e557bf1 100644 --- a/src/leapflow/plugins/marketplace/client.py +++ b/src/leapflow/plugins/marketplace/client.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Marketplace client: discover, download, verify, and install plugins. Prototype uses a local directory as the marketplace source. The diff --git a/src/leapflow/plugins/marketplace/http_source.py b/src/leapflow/plugins/marketplace/http_source.py index 8eda9bbb..c9325a10 100644 --- a/src/leapflow/plugins/marketplace/http_source.py +++ b/src/leapflow/plugins/marketplace/http_source.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """HTTP-based marketplace source for remote plugin discovery and download. Fetches plugin manifests and code from a remote HTTP(S) registry endpoint. diff --git a/src/leapflow/plugins/marketplace/manifest.py b/src/leapflow/plugins/marketplace/manifest.py index 003d2fce..13369758 100644 --- a/src/leapflow/plugins/marketplace/manifest.py +++ b/src/leapflow/plugins/marketplace/manifest.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Plugin manifest format for marketplace distribution. Supports Ed25519 signing for authenticity guarantees (in addition to diff --git a/src/leapflow/plugins/marketplace/server.py b/src/leapflow/plugins/marketplace/server.py index 11abbc99..071ed80d 100644 --- a/src/leapflow/plugins/marketplace/server.py +++ b/src/leapflow/plugins/marketplace/server.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Plugin Marketplace HTTP server. A minimal asyncio-based HTTP server that serves plugin manifests and code diff --git a/src/leapflow/plugins/protocol.py b/src/leapflow/plugins/protocol.py index a0dfbed7..f20289c0 100644 --- a/src/leapflow/plugins/protocol.py +++ b/src/leapflow/plugins/protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """ToolPlugin Protocol — the unified contract for tool plugin modules.""" from __future__ import annotations diff --git a/src/leapflow/plugins/registry.py b/src/leapflow/plugins/registry.py index 60fea000..92249ab8 100644 --- a/src/leapflow/plugins/registry.py +++ b/src/leapflow/plugins/registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """ToolPluginRegistry — the single entry point for tool system initialization.""" from __future__ import annotations diff --git a/src/leapflow/plugins/sandbox/__init__.py b/src/leapflow/plugins/sandbox/__init__.py index a77697c2..36709aae 100644 --- a/src/leapflow/plugins/sandbox/__init__.py +++ b/src/leapflow/plugins/sandbox/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Plugin sandbox for isolating untrusted third-party plugin execution.""" from leapflow.plugins.sandbox.protocol import SandboxRequest, SandboxResponse diff --git a/src/leapflow/plugins/sandbox/protocol.py b/src/leapflow/plugins/sandbox/protocol.py index 7e1562f0..6ed97ff2 100644 --- a/src/leapflow/plugins/sandbox/protocol.py +++ b/src/leapflow/plugins/sandbox/protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """JSON-RPC protocol between sandbox host and worker subprocess.""" from __future__ import annotations diff --git a/src/leapflow/plugins/sandbox/sandbox_host.py b/src/leapflow/plugins/sandbox/sandbox_host.py index 04eff838..87c6169e 100644 --- a/src/leapflow/plugins/sandbox/sandbox_host.py +++ b/src/leapflow/plugins/sandbox/sandbox_host.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Sandbox host: manages worker subprocesses and proxies tool calls. The host launches a worker subprocess per sandboxed plugin, then proxies diff --git a/src/leapflow/plugins/sandbox/worker.py b/src/leapflow/plugins/sandbox/worker.py index f8f9eccc..2fdaccba 100644 --- a/src/leapflow/plugins/sandbox/worker.py +++ b/src/leapflow/plugins/sandbox/worker.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Sandbox worker entrypoint. Runs in an isolated subprocess. Loads a plugin module, then serves tool invocation requests over stdin/stdout diff --git a/src/leapflow/plugins/scoped_registry.py b/src/leapflow/plugins/scoped_registry.py index 83327797..51873879 100644 --- a/src/leapflow/plugins/scoped_registry.py +++ b/src/leapflow/plugins/scoped_registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scoped lifecycle wrapper for ToolPluginRegistry. Provides reversible plugin registration: registering through this wrapper diff --git a/src/leapflow/plugins/selection_policy.py b/src/leapflow/plugins/selection_policy.py new file mode 100644 index 00000000..c8da9b89 --- /dev/null +++ b/src/leapflow/plugins/selection_policy.py @@ -0,0 +1,174 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Pluggable selection policy: which admissible candidate to use, and why. + +The resolver scores candidates; a policy chooses among them. Splitting the two is +what makes a learning strategy expressible at all. A ``CapabilityScorer`` sees one +candidate at a time and returns a number, which is enough for a weighted sum and +insufficient for anything else: an upper-confidence bound needs the total pull count +across *all* candidates, Thompson sampling needs to draw for all of them and compare, +and both need somewhere to put a posterior and a way to be told what happened. + +Three properties of this seam are load-bearing: + +* **The policy only ever sees admissible candidates.** Hard constraints -- a risk + ceiling, a missing platform affordance, a frozen plugin -- are applied by scorers + marking a component ``excluded``, and excluded candidates are filtered out before + the policy is consulted. Exploration must not be able to reach a tool the risk + policy refused; safety is not a term to be traded off. +* **The policy explains itself.** ``SelectionOutcome.reason`` is rendered on the + evolution board beside the choice. "Sampled at random" is not an explanation; a + posterior and an exploration bonus are. Without this a learning policy makes the + decision history unauditable, which costs more than the regret it saves. +* **Reward may abstain.** ``RewardSignal.value is None`` means "no information", + not "failure". The effect channel is three-valued and its abstain class is large: + a successful call whose handler reported no observable effect is the normal state + for every tool written before the convention existed. A policy that folded + abstention into failure would drive every arm's posterior toward zero and, through + trust, quarantine healthy plugins for a reporting omission. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Mapping, NamedTuple, Protocol, Sequence, runtime_checkable + +if TYPE_CHECKING: # pragma: no cover - typing only + from leapflow.domain.capability_requirement import CapabilityRequirement + from leapflow.plugins.capability_resolver import CandidateScore, ResolverContext + + +class RewardSignal(NamedTuple): + """What was learned from one selection, or that nothing was. + + ``value`` is ``None`` for an abstention and must leave a posterior untouched. + ``source`` names the channel so a policy can weight an execution result + differently from a verified effect -- they answer different questions ("did the + call work" versus "did the capability deliver"). + """ + + value: float | None + source: str = "" + confidence: float = 1.0 + + @property + def informative(self) -> bool: + return self.value is not None + + +@dataclass(frozen=True) +class SelectionOutcome: + """One policy's choice, with the explanation the board renders.""" + + selected: CandidateScore + policy_id: str + reason: str = "" + #: Whether this choice departed from the highest-scoring candidate. The single + #: most important thing to surface about a learning policy: an operator seeing a + #: lower-scored tool selected must be able to tell deliberate exploration from a + #: scoring bug. + explored: bool = False + arbitration_used: bool = False + + +@dataclass(frozen=True) +class PolicyDeps: + """Host-side services a policy may read. All optional, absence degrades. + + Injected at construction rather than reached for globally so a policy is + testable in isolation, and so the set of things a policy is allowed to touch is + visible in one place. + """ + + trust_ledger: Any = None + usage_tracker: Any = None + #: Optional tie-break hook, typically LLM-backed. A live object rather than a + #: configuration value, which is why it travels here and not in ``params``. + arbiter: Any = None + #: Durable home for whatever state a policy accumulates. ``None`` for the shipped + #: set, which is stateless. A policy that kept state in memory only would restart + #: cold on every daemon restart and never converge -- the trap trust already + #: learned (it flushes on level transitions plus ``atexit``) -- so the slot exists + #: for the case rather than being invented when it arrives. + stats_store: Any = None + + +@runtime_checkable +class SelectionPolicy(Protocol): + """Chooses among admissible candidates and learns from the result.""" + + policy_id: str + + def select( + self, + requirement: CapabilityRequirement, + eligible: Sequence[CandidateScore], + context: ResolverContext, + ) -> SelectionOutcome: + """Pick one candidate. ``eligible`` is never empty and never excluded.""" + ... + + def observe( + self, + requirement: CapabilityRequirement, + chosen_tool: str, + reward: RewardSignal, + ) -> None: + """Record what came of a selection. Must ignore an abstaining reward.""" + ... + + +@runtime_checkable +class SelectionPolicyPlugin(Protocol): + """Declares a policy and builds it from configuration. + + Deliberately shaped like ``LLMProviderPlugin`` rather than ``ToolPlugin``: a + selection policy is invoked by the framework inside a turn and needs host-side + services, so it must not be sandboxed, and it earns no Progressive Trust because + it executes no tools -- a trust level for it would be a meaningless number that + the plugin roster would nonetheless render. + """ + + @property + def policy_id(self) -> str: + """Stable identifier used in config, e.g. ``greedy``, ``thompson``.""" + ... + + @property + def display_name(self) -> str: + """Human-readable name for the config catalog and diagnostics.""" + ... + + def create(self, params: Mapping[str, Any], deps: PolicyDeps) -> SelectionPolicy: + """Build a configured policy. ``params`` is the open per-policy dict. + + Open rather than a typed schema on purpose: a fixed schema would mean + editing core settings to add a strategy, which is the opposite of the + extensibility this seam exists for. + """ + ... + + +@dataclass(frozen=True) +class PolicyDescriptor: + """What the config catalog and the board show about an available policy.""" + + policy_id: str + display_name: str + params: Mapping[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "policy_id": self.policy_id, + "display_name": self.display_name, + "params": dict(self.params), + } + + +__all__ = [ + "PolicyDeps", + "PolicyDescriptor", + "RewardSignal", + "SelectionOutcome", + "SelectionPolicy", + "SelectionPolicyPlugin", +] diff --git a/src/leapflow/plugins/selection_policy_registry.py b/src/leapflow/plugins/selection_policy_registry.py new file mode 100644 index 00000000..c183dabf --- /dev/null +++ b/src/leapflow/plugins/selection_policy_registry.py @@ -0,0 +1,316 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Registry for selection policy plugins: register, discover, build from config. + +Shaped after :class:`~leapflow.llm.provider_registry.LLMProviderRegistry`, which is +the established pattern for a core extension point: a Protocol, a registry populated +at startup, and config-driven instantiation. Deliberately *not* shaped after the tool +plugin pipeline -- a policy runs inside a turn and needs host services, so it must not +be sandboxed, approval-gated, or trust-graded. + +One difference from the provider registry is intentional. Registering a policy id that +already exists is **refused**, not silently replaced. The provider registry allows +override because swapping an LLM backend is an operator's prerogative; a selection +policy silently replaced by a third-party package would change how the framework +chooses its own tools with nothing on the record. First registration wins and the +challenger is reported, which is the same arbitration the tool-name namespace uses. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Mapping, Optional + +from leapflow.plugins.selection_policy import ( + PolicyDeps, + PolicyDescriptor, + SelectionPolicy, + SelectionPolicyPlugin, +) + +logger = logging.getLogger(__name__) + +#: Config key naming the active policy, and the id shipped as the default. +DEFAULT_POLICY_ID = "greedy" + +#: setuptools group a third-party package advertises a policy under. +ENTRY_POINT_GROUP = "leapflow.selection_policies" + + +class SelectionPolicyRegistry: + """Central registry of available selection policies. + + Not thread-safe: populated at startup and read afterwards, like every other + extension-point registry here. + """ + + def __init__(self) -> None: + self._plugins: Dict[str, SelectionPolicyPlugin] = {} + self._rejected: List[Dict[str, str]] = [] + self._active: SelectionPolicy | None = None + # The configuration the active instance was built from, so a change to any + # ``selection.*`` value -- not just the policy id -- rebuilds it. + self._built_from: Dict[str, Any] | None = None + + # ── registration ────────────────────────────────────────────────────── + + def register(self, plugin: SelectionPolicyPlugin) -> bool: + """Register a policy plugin. First registration of an id wins. + + Returns ``True`` when accepted. A rejection is recorded rather than raised: + one colliding package must not prevent every other policy from registering, + which is the same reason tool-name arbitration is non-fatal. + """ + policy_id = str(plugin.policy_id) + if not policy_id: + logger.warning("selection_policy: refusing a plugin with no policy_id") + return False + incumbent = self._plugins.get(policy_id) + if incumbent is not None: + self._rejected.append({ + "policy_id": policy_id, + "kept": str(incumbent.display_name), + "rejected": str(plugin.display_name), + }) + logger.warning( + "selection_policy: '%s' already registered by %s; rejecting %s", + policy_id, incumbent.display_name, plugin.display_name, + ) + return False + self._plugins[policy_id] = plugin + logger.debug("selection_policy: registered '%s'", policy_id) + return True + + def discover_entry_points(self) -> int: + """Register policies advertised by installed packages. Returns the count. + + A package that fails to load is skipped with a warning: a broken third-party + strategy must not stop the framework from choosing tools at all. + """ + found = 0 + try: + from importlib.metadata import entry_points + + for entry in entry_points(group=ENTRY_POINT_GROUP): + try: + if self.register(entry.load()()): + found += 1 + except Exception: # noqa: BLE001 - one bad package, not a startup failure + logger.warning( + "selection_policy: entry point %r failed to load", entry.name, + exc_info=True, + ) + except Exception: # noqa: BLE001 + logger.debug("selection_policy: entry point discovery unavailable", exc_info=True) + return found + + # ── construction ────────────────────────────────────────────────────── + + def create( + self, + policy_id: str, + params: Mapping[str, Any] | None = None, + deps: PolicyDeps | None = None, + ) -> Optional[SelectionPolicy]: + """Build one policy by id, or ``None`` when it is unknown or fails.""" + plugin = self._plugins.get(str(policy_id)) + if plugin is None: + logger.warning( + "selection_policy: %r is not registered; available: %s", + policy_id, ", ".join(self.available()) or "(none)", + ) + return None + try: + return plugin.create(dict(params or {}), deps or PolicyDeps()) + except Exception: # noqa: BLE001 - a broken policy must not break selection + logger.warning("selection_policy: %r failed to build", policy_id, exc_info=True) + return None + + def create_from_config( + self, config: Mapping[str, Any], deps: PolicyDeps | None = None + ) -> Optional[SelectionPolicy]: + """Build the configured policy, falling back to the default id. + + Reads ``selection_policy`` for the id and ``policy_params`` for the shared + parameter dict every policy filters for itself, so adding a strategy needs no + change to the settings schema. + """ + policy_id = str(config.get("selection_policy") or DEFAULT_POLICY_ID) + params = dict(config.get("policy_params") or {}) + policy = self.create(policy_id, params, deps) + if policy is None and policy_id != DEFAULT_POLICY_ID: + # An unknown id in config must not leave the resolver without a policy: + # falling back keeps tool selection working while the log names the + # misconfiguration. + logger.warning( + "selection_policy: falling back to %r after %r could not be built", + DEFAULT_POLICY_ID, policy_id, + ) + return self.create(DEFAULT_POLICY_ID, {}, deps) + return policy + + def activate( + self, deps: PolicyDeps | None = None, *, settings: Any = None + ) -> Optional[SelectionPolicy]: + """Build the configured policy once and keep it as the process's active one. + + Cached for correctness, not speed. A learning policy carries a posterior, so + building a fresh instance per call would hand every observation to a throwaway + object and nothing would ever accumulate -- silently, since each individual + call looks fine. ``LLMProviderRegistry`` caches its instances for the same + reason a provider holds a session. + + ``settings`` is the *live* configuration, pushed in by the caller. It has to be + pushed: ``get_settings()`` is a boot snapshot with no refresh path, so a + component that reads it for a mutable setting reads a value from process start + forever, and ``selection.policy`` would present itself as hot-reloadable + through ``leap config`` while never taking effect. The whole object rather than + individual values, so a new policy parameter needs no new argument here. + + Switching is self-correcting rather than driven by an external invalidation + hook, so it holds for every entry point -- in-process CLI, daemon, tests -- + without each having to remember to call it. A *stateful* policy loses its + in-memory state on a switch, which is why posteriors belong in a store rather + than the instance. + + Called by the component that *selects*, because that is the one holding the + live services a policy may read. Reporters use :meth:`current` instead, so a + cold-path caller can never install a policy with no dependencies. + """ + config = ( + { + "selection_policy": getattr(settings, "selection_policy", "") or DEFAULT_POLICY_ID, + "policy_params": policy_params_from_settings(settings), + } + if settings is not None + else _settings_config() + ) + wanted = str(config.get("selection_policy") or DEFAULT_POLICY_ID) + if ( + self._active is not None + # Compared on the whole effective configuration, not just the id: every + # ``selection.*`` key presents itself as hot-reloadable through + # ``leap config``, so changing an exploration coefficient or an + # experiment's arms has to take effect too. Comparing ids alone left + # those silently pinned to their process-start values. + and self._built_from != config + # Only when the wanted id is registered. An unknown id already fell back + # to the default and logged; rebuilding on every activation would thrash + # and re-log for the life of the misconfiguration. + and wanted in self._plugins + ): + logger.info( + "selection_policy: rebuilding %r after a configuration change", + self._active.policy_id, + ) + self._active = None + if self._active is None: + # A rebuilt policy loses its in-memory state, which is why a posterior + # belongs in the durable store rather than the instance. + self._active = self.create_from_config(config, deps) + self._built_from = dict(config) + return self._active + + def current(self) -> Optional[SelectionPolicy]: + """The active policy, or ``None`` if nothing has selected yet. + + Deliberately non-creating. A reporter that built the policy would cache one + with no host dependencies and win the race against the real owner; and with + nothing selected yet there is by definition no decision to report on. + """ + return self._active + + # ── introspection ───────────────────────────────────────────────────── + + def available(self) -> List[str]: + return sorted(self._plugins) + + def describe(self) -> List[PolicyDescriptor]: + """Every registered policy. Read by the config catalog's value hint, so an + operator setting ``selection.policy`` can discover the ids that exist -- + including ones a third-party package registered. + """ + return [ + PolicyDescriptor(policy_id=pid, display_name=str(plugin.display_name)) + for pid, plugin in sorted(self._plugins.items()) + ] + + @property + def rejected(self) -> List[Dict[str, str]]: + """Collisions, surfaced rather than silently dropped.""" + return list(self._rejected) + + +_registry: SelectionPolicyRegistry | None = None + + +def _settings_config() -> Dict[str, Any]: + """The policy selection read from settings, in one place. + + Both the selecting component and the reporting one must agree on which policy is + active; reading the key in two places is how they would come to disagree. + + Translates the flat, ``leap config``-discoverable ``selection_*`` settings into the + per-policy ``params`` each plugin reads. Flat keys are the durable surface -- a + nested dict in ``Settings`` would be a YAML-only knob, which the config contract + forbids -- while ``params`` stays open so a policy arriving through the entry point + group has somewhere to be configured from without adding fields to ``Settings``. + """ + try: + from leapflow.config import get_settings + + settings = get_settings() + except Exception: # noqa: BLE001 - an unreadable config still selects tools + logger.debug("selection_policy: settings unavailable, using the default", exc_info=True) + return {"selection_policy": DEFAULT_POLICY_ID, "policy_params": {}} + + return { + "selection_policy": getattr(settings, "selection_policy", "") or DEFAULT_POLICY_ID, + "policy_params": policy_params_from_settings(settings), + } + + +def policy_params_from_settings(settings: Any) -> Dict[str, Any]: + """The shared parameter dict every policy filters for itself. + + Empty for the shipped set: ``GreedyPolicy`` takes no configuration. Kept as the + seam's contract rather than removed, because a third-party policy registered + through the entry point group cannot add typed fields to ``Settings`` and this open + dict is its only configuration path. + """ + params: Dict[str, Any] = {} + extra = getattr(settings, "policy_params", None) + if isinstance(extra, Mapping): + params.update({str(k): v for k, v in extra.items()}) + return params + + +def get_selection_policy_registry() -> SelectionPolicyRegistry: + """The process-wide registry, populated with built-ins on first use. + + A singleton for the same reason the plugin and provider registries are: the set + of available policies is a property of the process, and the cold-path sweep needs + to reach the same policy the resolver used in order to report back to it. + """ + global _registry + if _registry is None: + _registry = SelectionPolicyRegistry() + from leapflow.plugins._builtin_policies import register_builtin_policies + + register_builtin_policies(_registry) + _registry.discover_entry_points() + return _registry + + +def reset_selection_policy_registry() -> None: + """Drop the singleton, including any active instance. For tests.""" + global _registry + _registry = None + + +__all__ = [ + "DEFAULT_POLICY_ID", + "ENTRY_POINT_GROUP", + "SelectionPolicyRegistry", + "get_selection_policy_registry", + "reset_selection_policy_registry", +] diff --git a/src/leapflow/plugins/tool_plugins/__init__.py b/src/leapflow/plugins/tool_plugins/__init__.py index cec08855..ae9d5eb1 100644 --- a/src/leapflow/plugins/tool_plugins/__init__.py +++ b/src/leapflow/plugins/tool_plugins/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Built-in tool plugin discovery. Each module in this package exposes a module-level ``plugin`` instance diff --git a/src/leapflow/plugins/tool_plugins/code_intel.py b/src/leapflow/plugins/tool_plugins/code_intel.py index c70172d5..36e40e40 100644 --- a/src/leapflow/plugins/tool_plugins/code_intel.py +++ b/src/leapflow/plugins/tool_plugins/code_intel.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Code intelligence plugin — document symbols and repository map.""" from __future__ import annotations diff --git a/src/leapflow/plugins/tool_plugins/config_tools.py b/src/leapflow/plugins/tool_plugins/config_tools.py index f3be9da4..baabba5d 100644 --- a/src/leapflow/plugins/tool_plugins/config_tools.py +++ b/src/leapflow/plugins/tool_plugins/config_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Configuration tools plugin — list, get, and set LeapFlow settings.""" from __future__ import annotations diff --git a/src/leapflow/plugins/tool_plugins/desktop_semantic.py b/src/leapflow/plugins/tool_plugins/desktop_semantic.py index 80a1a4c4..7b84ad52 100644 --- a/src/leapflow/plugins/tool_plugins/desktop_semantic.py +++ b/src/leapflow/plugins/tool_plugins/desktop_semantic.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Desktop semantic tools plugin — exposes SemanticAdapter tools to the unified tool system. Landing C: this plugin is the single registration site for semantic desktop diff --git a/src/leapflow/plugins/tool_plugins/dev_tools.py b/src/leapflow/plugins/tool_plugins/dev_tools.py index 51018ca9..2b19b848 100644 --- a/src/leapflow/plugins/tool_plugins/dev_tools.py +++ b/src/leapflow/plugins/tool_plugins/dev_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Dev tools plugin — test runner and linter integration.""" from __future__ import annotations diff --git a/src/leapflow/plugins/tool_plugins/file_ops.py b/src/leapflow/plugins/tool_plugins/file_ops.py index 3259cb43..c89ac621 100644 --- a/src/leapflow/plugins/tool_plugins/file_ops.py +++ b/src/leapflow/plugins/tool_plugins/file_ops.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """File operations plugin — list, read, write, search, find, edit.""" from __future__ import annotations diff --git a/src/leapflow/plugins/tool_plugins/gateway.py b/src/leapflow/plugins/tool_plugins/gateway.py index 86156554..a1914f18 100644 --- a/src/leapflow/plugins/tool_plugins/gateway.py +++ b/src/leapflow/plugins/tool_plugins/gateway.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Gateway tools plugin — exposes platform connectivity and messaging as a ToolPlugin.""" from __future__ import annotations diff --git a/src/leapflow/plugins/tool_plugins/hub.py b/src/leapflow/plugins/tool_plugins/hub.py index 399f1011..aec8da53 100644 --- a/src/leapflow/plugins/tool_plugins/hub.py +++ b/src/leapflow/plugins/tool_plugins/hub.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hub tools plugin — exposes Hub operations (push, pull, search, sync) as a ToolPlugin.""" from __future__ import annotations diff --git a/src/leapflow/plugins/tool_plugins/memory_research.py b/src/leapflow/plugins/tool_plugins/memory_research.py index b59dee74..59db4422 100644 --- a/src/leapflow/plugins/tool_plugins/memory_research.py +++ b/src/leapflow/plugins/tool_plugins/memory_research.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Memory & Research plugin — memory search/add and research ledger tools. These tools have late-binding dependencies on engine internals (MemoryManager, diff --git a/src/leapflow/plugins/tool_plugins/orchestration.py b/src/leapflow/plugins/tool_plugins/orchestration.py index 57ad149e..90bc7dc0 100644 --- a/src/leapflow/plugins/tool_plugins/orchestration.py +++ b/src/leapflow/plugins/tool_plugins/orchestration.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Orchestration & System plugin — capability expansion, subagent delegation, re-entry scheduling. These tools have late-binding dependencies on engine internals diff --git a/src/leapflow/plugins/tool_plugins/scm_git.py b/src/leapflow/plugins/tool_plugins/scm_git.py index 901e70f4..037eab25 100644 --- a/src/leapflow/plugins/tool_plugins/scm_git.py +++ b/src/leapflow/plugins/tool_plugins/scm_git.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """SCM/Git plugin — structured git operations (sync, query, write).""" from __future__ import annotations diff --git a/src/leapflow/plugins/tool_plugins/self_management.py b/src/leapflow/plugins/tool_plugins/self_management.py index 69d433f1..826e4971 100644 --- a/src/leapflow/plugins/tool_plugins/self_management.py +++ b/src/leapflow/plugins/tool_plugins/self_management.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Self-Management plugin — lets the Agent introspect and manage its own plugin composition. This is the Phase 2.4 Self-Modification MVP. It exposes twelve tools: @@ -56,6 +57,31 @@ logger = logging.getLogger(__name__) + +def _declared_capabilities(proposal: Any) -> tuple[str, ...]: + """The capability names a proposal was raised for, from its own evidence. + + Read off ``GapEvidence.metadata`` rather than re-derived, because the producer + already recorded it there: ``capability_gap_detector`` puts ``intent.capability`` + into the metadata of a world-model proposal. Re-deriving it from the summary would + be inferring a capability name from text, which the observation layer forbids. + + Empty for a proposal that carries none -- notably the ``unknown_tool`` path, whose + "capability" is the missing tool's invented name and therefore not a name any tool + should declare. Generating with no declaration is still better than generating with + a wrong one. + """ + if proposal is None: + return () + names: list[str] = [] + for evidence in getattr(proposal, "evidence", ()) or (): + for key, value in dict(getattr(evidence, "metadata", ()) or ()).items(): + if str(key) == "capability" and str(value).strip(): + candidate = str(value).strip() + if candidate not in names: + names.append(candidate) + return tuple(names) + class SelfManagementPlugin: """ToolPlugin exposing the Agent's own plugin management surface.""" @@ -602,7 +628,11 @@ async def _plugin_generate_handler( try: generator = PluginGenerator(llm_provider=self._llm_provider) - request = PluginGenerationRequest(plugin_id=plugin_id, description=description) + request = PluginGenerationRequest( + plugin_id=plugin_id, + description=description, + provides_capabilities=_declared_capabilities(proposal if proposal_id else None), + ) result = await generator.generate_and_validate(request) if proposal_id: result["proposal_id"] = proposal_id diff --git a/src/leapflow/plugins/tool_plugins/shell_terminal.py b/src/leapflow/plugins/tool_plugins/shell_terminal.py index 955dcb26..394640eb 100644 --- a/src/leapflow/plugins/tool_plugins/shell_terminal.py +++ b/src/leapflow/plugins/tool_plugins/shell_terminal.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shell and terminal session plugin — one-shot commands and persistent sessions.""" from __future__ import annotations diff --git a/src/leapflow/plugins/tool_plugins/skill_discovery.py b/src/leapflow/plugins/tool_plugins/skill_discovery.py index 5196c769..0d490894 100644 --- a/src/leapflow/plugins/tool_plugins/skill_discovery.py +++ b/src/leapflow/plugins/tool_plugins/skill_discovery.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skill discovery plugin — list and view learned skills.""" from __future__ import annotations diff --git a/src/leapflow/plugins/tool_plugins/system_info.py b/src/leapflow/plugins/tool_plugins/system_info.py index 65611229..87f59087 100644 --- a/src/leapflow/plugins/tool_plugins/system_info.py +++ b/src/leapflow/plugins/tool_plugins/system_info.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """System information plugin — current time and environment info.""" from __future__ import annotations diff --git a/src/leapflow/plugins/tool_plugins/text_utils.py b/src/leapflow/plugins/tool_plugins/text_utils.py index 2b20cb01..397ecbd0 100644 --- a/src/leapflow/plugins/tool_plugins/text_utils.py +++ b/src/leapflow/plugins/tool_plugins/text_utils.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Text utilities plugin — regex search and string replace. Pilot migration: validates the full ToolPlugin pipeline. diff --git a/src/leapflow/plugins/tool_plugins/web_access.py b/src/leapflow/plugins/tool_plugins/web_access.py index 2d464f6d..753167f7 100644 --- a/src/leapflow/plugins/tool_plugins/web_access.py +++ b/src/leapflow/plugins/tool_plugins/web_access.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Web access plugin — read-only HTTP fetch for the agent loop.""" from __future__ import annotations diff --git a/src/leapflow/privacy/__init__.py b/src/leapflow/privacy/__init__.py index de8cf2f8..f2dcf30c 100644 --- a/src/leapflow/privacy/__init__.py +++ b/src/leapflow/privacy/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Privacy compliance framework — configuration-driven data retention and user control.""" from leapflow.privacy.policy import PrivacyPolicy, DataRetentionConfig, PrivacyManager diff --git a/src/leapflow/privacy/policy.py b/src/leapflow/privacy/policy.py index d2ccd34f..d8f6c51d 100644 --- a/src/leapflow/privacy/policy.py +++ b/src/leapflow/privacy/policy.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Privacy policy — configuration-driven data retention, opt-out, and audit. Design principles (from Active Learning Design doc): diff --git a/src/leapflow/prompts/__init__.py b/src/leapflow/prompts/__init__.py index a366af5d..579bbbc1 100644 --- a/src/leapflow/prompts/__init__.py +++ b/src/leapflow/prompts/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Prompt templates.""" from leapflow.prompts.templates import REACT_SYSTEM, user_block diff --git a/src/leapflow/prompts/templates.py b/src/leapflow/prompts/templates.py index 6ccee228..6ef99ee4 100644 --- a/src/leapflow/prompts/templates.py +++ b/src/leapflow/prompts/templates.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """System prompts and templates for routing and ReAct.""" from __future__ import annotations diff --git a/src/leapflow/recording/__init__.py b/src/leapflow/recording/__init__.py index ea6819ac..fbbff6bd 100644 --- a/src/leapflow/recording/__init__.py +++ b/src/leapflow/recording/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Real-time recording layer — attention filtering, event capture, and frame storage.""" from leapflow.recording.attention import ( diff --git a/src/leapflow/recording/attention.py b/src/leapflow/recording/attention.py index c5835c26..ebcdddad 100644 --- a/src/leapflow/recording/attention.py +++ b/src/leapflow/recording/attention.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Context Learning Attention Mechanism — signal/noise filtering for demonstration recording. Implements layered attention filters that improve the signal-to-noise ratio diff --git a/src/leapflow/recording/attention_tuner.py b/src/leapflow/recording/attention_tuner.py index 8d2f58f1..5dcd1638 100644 --- a/src/leapflow/recording/attention_tuner.py +++ b/src/leapflow/recording/attention_tuner.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """AttentionTuner — bridges world model learning signals to attention filter parameters. Provides the meta-cognitive feedback loop for the attention mechanism: diff --git a/src/leapflow/recording/field_policy_loader.py b/src/leapflow/recording/field_policy_loader.py index 725448ba..542dba88 100644 --- a/src/leapflow/recording/field_policy_loader.py +++ b/src/leapflow/recording/field_policy_loader.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Perceptual Field Policy Loader — builtin rules, YAML persistence, goal inference. Loads and merges rules from multiple sources: diff --git a/src/leapflow/recording/health.py b/src/leapflow/recording/health.py index 6db9859a..b3b2fbdf 100644 --- a/src/leapflow/recording/health.py +++ b/src/leapflow/recording/health.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Recording Health Monitor — real-time degradation detection during learn recording. Detects and warns about systemic issues that would silently corrupt the diff --git a/src/leapflow/recording/perceptual_field.py b/src/leapflow/recording/perceptual_field.py index 2cb2e2cc..c173109a 100644 --- a/src/leapflow/recording/perceptual_field.py +++ b/src/leapflow/recording/perceptual_field.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Perceptual Field Engine — context-aware perception control within apps. Architecture: diff --git a/src/leapflow/recording/recorder.py b/src/leapflow/recording/recorder.py index 893fa969..d59199cd 100644 --- a/src/leapflow/recording/recorder.py +++ b/src/leapflow/recording/recorder.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Demonstration recorder — converts EventBus stream into trajectories. Plugs into the existing EventBus.subscribe() mechanism as a zero-intrusion diff --git a/src/leapflow/scheduler/__init__.py b/src/leapflow/scheduler/__init__.py index fe7bcbc1..d6911a3e 100644 --- a/src/leapflow/scheduler/__init__.py +++ b/src/leapflow/scheduler/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Long-horizon async task scheduler — local and cloud execution.""" from leapflow.scheduler.types import ( diff --git a/src/leapflow/scheduler/cloud_dispatcher.py b/src/leapflow/scheduler/cloud_dispatcher.py index ca673a9d..07dfe39a 100644 --- a/src/leapflow/scheduler/cloud_dispatcher.py +++ b/src/leapflow/scheduler/cloud_dispatcher.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cloud dispatcher — orchestrates cloud task deployment lifecycle. Workflow: package → create worker → inject secrets → deploy → monitor. diff --git a/src/leapflow/scheduler/compute/__init__.py b/src/leapflow/scheduler/compute/__init__.py index aaed145c..7a602c2c 100644 --- a/src/leapflow/scheduler/compute/__init__.py +++ b/src/leapflow/scheduler/compute/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Compute backends for cloud task execution.""" from leapflow.scheduler.compute.protocol import ComputeBackend diff --git a/src/leapflow/scheduler/compute/modelscope_studio.py b/src/leapflow/scheduler/compute/modelscope_studio.py index bda9ea4b..37098284 100644 --- a/src/leapflow/scheduler/compute/modelscope_studio.py +++ b/src/leapflow/scheduler/compute/modelscope_studio.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """ModelScope Studio compute backend implementation. Each task is deployed as a private Docker-based Studio that runs a LeapFlow diff --git a/src/leapflow/scheduler/compute/protocol.py b/src/leapflow/scheduler/compute/protocol.py index f084086b..1328c181 100644 --- a/src/leapflow/scheduler/compute/protocol.py +++ b/src/leapflow/scheduler/compute/protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Compute backend protocol for cloud task execution. Defines the abstract interface that all compute backends must implement. diff --git a/src/leapflow/scheduler/coordinator.py b/src/leapflow/scheduler/coordinator.py index 69d817dc..e95f291c 100644 --- a/src/leapflow/scheduler/coordinator.py +++ b/src/leapflow/scheduler/coordinator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unified task orchestrator — routes armed tasks to local or cloud execution. Tier decision heuristic: diff --git a/src/leapflow/scheduler/local_scheduler.py b/src/leapflow/scheduler/local_scheduler.py index 69a84e02..e43c7359 100644 --- a/src/leapflow/scheduler/local_scheduler.py +++ b/src/leapflow/scheduler/local_scheduler.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Local async scheduler — runs as background task in event loop. Design principles: diff --git a/src/leapflow/scheduler/reentry_driver.py b/src/leapflow/scheduler/reentry_driver.py index 5c28b342..cabb4068 100644 --- a/src/leapflow/scheduler/reentry_driver.py +++ b/src/leapflow/scheduler/reentry_driver.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Dispatches due re-entry triggers by seeding an Orient-seeded run (S2, phase N3). Pure orchestration: reads due TIME triggers from a ``ReentryStore``, atomically diff --git a/src/leapflow/scheduler/reentry_send.py b/src/leapflow/scheduler/reentry_send.py index 9b0eeef9..698731aa 100644 --- a/src/leapflow/scheduler/reentry_send.py +++ b/src/leapflow/scheduler/reentry_send.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """S2 outbound SO1+SO4: outbound contracts, target resolution, and the pure governance decision for autonomous re-entry sends. diff --git a/src/leapflow/scheduler/reentry_service.py b/src/leapflow/scheduler/reentry_service.py index b9c1ace7..365f4ee7 100644 --- a/src/leapflow/scheduler/reentry_service.py +++ b/src/leapflow/scheduler/reentry_service.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Re-entry orchestration service (S2 phases N3b–N5). Consolidates the two trigger sources (time ticks and gateway events) behind one diff --git a/src/leapflow/scheduler/store.py b/src/leapflow/scheduler/store.py index a7283c59..1dcf9630 100644 --- a/src/leapflow/scheduler/store.py +++ b/src/leapflow/scheduler/store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DuckDB-backed persistence for armed tasks. Provides atomic CRUD and query operations for the scheduler. diff --git a/src/leapflow/scheduler/triggers/__init__.py b/src/leapflow/scheduler/triggers/__init__.py index 4e909c67..dfe1ff09 100644 --- a/src/leapflow/scheduler/triggers/__init__.py +++ b/src/leapflow/scheduler/triggers/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Trigger factory: create Trigger instances from type string and config dict.""" from __future__ import annotations diff --git a/src/leapflow/scheduler/triggers/condition.py b/src/leapflow/scheduler/triggers/condition.py index 9680c370..8084477c 100644 --- a/src/leapflow/scheduler/triggers/condition.py +++ b/src/leapflow/scheduler/triggers/condition.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Condition-based trigger: fires when a declarative condition is met. Supports simple comparison expressions like: diff --git a/src/leapflow/scheduler/triggers/cron.py b/src/leapflow/scheduler/triggers/cron.py index c444d117..8d913841 100644 --- a/src/leapflow/scheduler/triggers/cron.py +++ b/src/leapflow/scheduler/triggers/cron.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cron-expression trigger with graceful fallback. Uses ``croniter`` when available for full cron expression support. diff --git a/src/leapflow/scheduler/triggers/event.py b/src/leapflow/scheduler/triggers/event.py index cf221091..c1ea3f0d 100644 --- a/src/leapflow/scheduler/triggers/event.py +++ b/src/leapflow/scheduler/triggers/event.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Event-based trigger: fires when an external event matches a pattern.""" from __future__ import annotations diff --git a/src/leapflow/scheduler/triggers/interval.py b/src/leapflow/scheduler/triggers/interval.py index 12f3fe18..9f35d4b1 100644 --- a/src/leapflow/scheduler/triggers/interval.py +++ b/src/leapflow/scheduler/triggers/interval.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Interval-based trigger: fires every N seconds/minutes/hours/days.""" from __future__ import annotations diff --git a/src/leapflow/scheduler/types.py b/src/leapflow/scheduler/types.py index 0ba96bff..baecaf60 100644 --- a/src/leapflow/scheduler/types.py +++ b/src/leapflow/scheduler/types.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Core type definitions for the long-horizon async task scheduler.""" from __future__ import annotations diff --git a/src/leapflow/scheduler/worker_packager.py b/src/leapflow/scheduler/worker_packager.py index 866e67ce..d78ee222 100644 --- a/src/leapflow/scheduler/worker_packager.py +++ b/src/leapflow/scheduler/worker_packager.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Worker packager — generates self-contained Docker packages for cloud deployment. Produces a temporary directory containing all files needed to run a LeapFlow diff --git a/src/leapflow/security/__init__.py b/src/leapflow/security/__init__.py index 8ae3617c..222678ba 100644 --- a/src/leapflow/security/__init__.py +++ b/src/leapflow/security/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Security module — redaction, threat scanning, approval, and trust boundary enforcement.""" from leapflow.security.actions import ActionDescriptor, ActionEffect, ActionKind, ActionOrigin diff --git a/src/leapflow/security/actions.py b/src/leapflow/security/actions.py index 2b16884e..55ce462e 100644 --- a/src/leapflow/security/actions.py +++ b/src/leapflow/security/actions.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Structured action descriptors for human approval decisions.""" from __future__ import annotations diff --git a/src/leapflow/security/approval.py b/src/leapflow/security/approval.py index 1225dfe1..f1474061 100644 --- a/src/leapflow/security/approval.py +++ b/src/leapflow/security/approval.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unified approval framework for actions requiring human confirmation. This module is the compatibility-facing API for LeapFlow approvals. It keeps diff --git a/src/leapflow/security/grants.py b/src/leapflow/security/grants.py index 24ed7810..4a3896ce 100644 --- a/src/leapflow/security/grants.py +++ b/src/leapflow/security/grants.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Approval grant and audit stores.""" from __future__ import annotations diff --git a/src/leapflow/security/network.py b/src/leapflow/security/network.py index fd61cc74..41624483 100644 --- a/src/leapflow/security/network.py +++ b/src/leapflow/security/network.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Outbound URL classification for the network egress gate. Splitting this out of ``risk.py`` keeps the risk classifier synchronous and diff --git a/src/leapflow/security/orchestrator.py b/src/leapflow/security/orchestrator.py index ac0e5893..62e5ce15 100644 --- a/src/leapflow/security/orchestrator.py +++ b/src/leapflow/security/orchestrator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Approval orchestration: policy, grants, prompting, and audit.""" from __future__ import annotations diff --git a/src/leapflow/security/path_sensitivity.py b/src/leapflow/security/path_sensitivity.py index 0c0540dc..670fdb72 100644 --- a/src/leapflow/security/path_sensitivity.py +++ b/src/leapflow/security/path_sensitivity.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Path sensitivity classification for local file access governance. The classifier is intentionally policy-oriented and tool-agnostic: it maps a diff --git a/src/leapflow/security/permission_failures.py b/src/leapflow/security/permission_failures.py index fab9bea9..da7257d6 100644 --- a/src/leapflow/security/permission_failures.py +++ b/src/leapflow/security/permission_failures.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shared permission-failure predicates for agent and TUI recovery flows.""" from __future__ import annotations diff --git a/src/leapflow/security/policy.py b/src/leapflow/security/policy.py index d336ed67..f34337b5 100644 --- a/src/leapflow/security/policy.py +++ b/src/leapflow/security/policy.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Approval policy evaluation built on structured risk assessments.""" from __future__ import annotations diff --git a/src/leapflow/security/redact.py b/src/leapflow/security/redact.py index 43a10815..482309a8 100644 --- a/src/leapflow/security/redact.py +++ b/src/leapflow/security/redact.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Secret redaction for logs, tool outputs, and display-layer text. Design (inspired by hermes-agent/redact.py): diff --git a/src/leapflow/security/risk.py b/src/leapflow/security/risk.py index 6578517e..0f5c519e 100644 --- a/src/leapflow/security/risk.py +++ b/src/leapflow/security/risk.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Risk assessment for structured approval actions.""" from __future__ import annotations diff --git a/src/leapflow/security/secrets.py b/src/leapflow/security/secrets.py index 533d4f6f..d96ff701 100644 --- a/src/leapflow/security/secrets.py +++ b/src/leapflow/security/secrets.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unified secret vault for LeapFlow credentials.""" from __future__ import annotations diff --git a/src/leapflow/security/send_trust.py b/src/leapflow/security/send_trust.py index 076e7b1a..be4f7a60 100644 --- a/src/leapflow/security/send_trust.py +++ b/src/leapflow/security/send_trust.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """S2 outbound SO2: send-scope Progressive Trust ledger. Autonomous re-entry has no synchronous human approver, so an outbound send is diff --git a/src/leapflow/security/threat_patterns.py b/src/leapflow/security/threat_patterns.py index 90f9109b..93d9f659 100644 --- a/src/leapflow/security/threat_patterns.py +++ b/src/leapflow/security/threat_patterns.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Threat pattern scanning for prompt injection and adversarial content. Layered defense: diff --git a/src/leapflow/signal_fusion/__init__.py b/src/leapflow/signal_fusion/__init__.py index d238af09..091f9ce1 100644 --- a/src/leapflow/signal_fusion/__init__.py +++ b/src/leapflow/signal_fusion/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """MHMS-SF: Multi-source Heterogeneous Multi-scale Signal Fusion. This module is the orchestration layer for fusing visual, event, and diff --git a/src/leapflow/signal_fusion/action_agent.py b/src/leapflow/signal_fusion/action_agent.py index d4494cfe..1612aec8 100644 --- a/src/leapflow/signal_fusion/action_agent.py +++ b/src/leapflow/signal_fusion/action_agent.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Action-scale fusion: converts raw visual actions + system events into AtomicActions. Replaces the former ActionScaleAligner with a simpler, OCP-friendly design. diff --git a/src/leapflow/signal_fusion/cross_app.py b/src/leapflow/signal_fusion/cross_app.py index fb317b59..b5ee546e 100644 --- a/src/leapflow/signal_fusion/cross_app.py +++ b/src/leapflow/signal_fusion/cross_app.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cross-app context tracking for workflow hypothesis generation. Maintains a stateful model of app transitions, clipboard carry payloads, diff --git a/src/leapflow/signal_fusion/episode_agent.py b/src/leapflow/signal_fusion/episode_agent.py index 7f29377e..3cf4bb09 100644 --- a/src/leapflow/signal_fusion/episode_agent.py +++ b/src/leapflow/signal_fusion/episode_agent.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Episode-scale fusion: cross-app workflow graph construction. Assembles Segments into EnrichedEpisodes with WorkflowGraph DAGs, diff --git a/src/leapflow/signal_fusion/integrator.py b/src/leapflow/signal_fusion/integrator.py index 88928b9c..86129878 100644 --- a/src/leapflow/signal_fusion/integrator.py +++ b/src/leapflow/signal_fusion/integrator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cross-scale bidirectional integration. Bottom-up: enriches Segments with statistical summaries from AtomicActions. diff --git a/src/leapflow/signal_fusion/pipeline.py b/src/leapflow/signal_fusion/pipeline.py index 4016485e..d265f2a6 100644 --- a/src/leapflow/signal_fusion/pipeline.py +++ b/src/leapflow/signal_fusion/pipeline.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """MHMS-SF Fusion Pipeline — orchestrates multi-scale fusion agents. Chains ScaleFusionAgent implementations in sequence: diff --git a/src/leapflow/signal_fusion/protocol.py b/src/leapflow/signal_fusion/protocol.py index a03b1f2f..d59abce3 100644 --- a/src/leapflow/signal_fusion/protocol.py +++ b/src/leapflow/signal_fusion/protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Protocols and context containers for the MHMS-SF fusion pipeline. Defines: diff --git a/src/leapflow/signal_fusion/quality.py b/src/leapflow/signal_fusion/quality.py index a25ad8ce..14b1f152 100644 --- a/src/leapflow/signal_fusion/quality.py +++ b/src/leapflow/signal_fusion/quality.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Fusion quality assessment. Computes quality metrics from fusion results to guide downstream diff --git a/src/leapflow/signal_fusion/segment_agent.py b/src/leapflow/signal_fusion/segment_agent.py index 07202276..690d8397 100644 --- a/src/leapflow/signal_fusion/segment_agent.py +++ b/src/leapflow/signal_fusion/segment_agent.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Segment-scale fusion: sub-task identification with wait-period awareness. Groups AtomicActions into Segments based on app transitions, temporal diff --git a/src/leapflow/signal_fusion/types.py b/src/leapflow/signal_fusion/types.py index 2e86badd..e813cffb 100644 --- a/src/leapflow/signal_fusion/types.py +++ b/src/leapflow/signal_fusion/types.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Core data types for multi-source heterogeneous multi-scale signal fusion. Defines the fused data model hierarchy: diff --git a/src/leapflow/signal_fusion/wait_classifier.py b/src/leapflow/signal_fusion/wait_classifier.py index 54e2774a..047fab36 100644 --- a/src/leapflow/signal_fusion/wait_classifier.py +++ b/src/leapflow/signal_fusion/wait_classifier.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Silent period classification for gaps between user actions. Classifies temporal gaps as normal pauses, AI generation waits, diff --git a/src/leapflow/skills/__init__.py b/src/leapflow/skills/__init__.py index 4162d62a..cd8c8f29 100644 --- a/src/leapflow/skills/__init__.py +++ b/src/leapflow/skills/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skills package — runtime skill registry, activation, and execution.""" from leapflow.skills.index import SkillEntry, SkillIndex diff --git a/src/leapflow/skills/action_policy.py b/src/leapflow/skills/action_policy.py index 99875e9e..80ac001c 100644 --- a/src/leapflow/skills/action_policy.py +++ b/src/leapflow/skills/action_policy.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tool-level action policy — human-in-the-loop gate for the ReAct executor. Intercepts tool calls between LLM output parsing and platform execution, diff --git a/src/leapflow/skills/activator.py b/src/leapflow/skills/activator.py index 46fec89e..ad605054 100644 --- a/src/leapflow/skills/activator.py +++ b/src/leapflow/skills/activator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skill activation — compile generated code into executable skills. Bridges the gap between StoredSkill (declarative data) and Skill (executable). diff --git a/src/leapflow/skills/builtin/__init__.py b/src/leapflow/skills/builtin/__init__.py index 318e5ef0..ea286356 100644 --- a/src/leapflow/skills/builtin/__init__.py +++ b/src/leapflow/skills/builtin/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Built-in package exports.""" from leapflow.skills.builtin import app_launcher, clipboard_manager, file_organizer diff --git a/src/leapflow/skills/builtin/app_launcher.py b/src/leapflow/skills/builtin/app_launcher.py index 3ed59f21..a2ca232a 100644 --- a/src/leapflow/skills/builtin/app_launcher.py +++ b/src/leapflow/skills/builtin/app_launcher.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Built-in app launcher / basic automation skill.""" from __future__ import annotations diff --git a/src/leapflow/skills/builtin/clipboard_manager.py b/src/leapflow/skills/builtin/clipboard_manager.py index e853a4b4..160cbf10 100644 --- a/src/leapflow/skills/builtin/clipboard_manager.py +++ b/src/leapflow/skills/builtin/clipboard_manager.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Built-in clipboard manager skill.""" from __future__ import annotations diff --git a/src/leapflow/skills/builtin/file_organizer.py b/src/leapflow/skills/builtin/file_organizer.py index d5531e31..041fe1bf 100644 --- a/src/leapflow/skills/builtin/file_organizer.py +++ b/src/leapflow/skills/builtin/file_organizer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Smart file organization skill (metadata-first + LLM plan + moves).""" from __future__ import annotations diff --git a/src/leapflow/skills/conditions.py b/src/leapflow/skills/conditions.py index 94f2703f..1466a400 100644 --- a/src/leapflow/skills/conditions.py +++ b/src/leapflow/skills/conditions.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Pre/postcondition verification for skill execution. Evaluates declarative condition strings against the runtime environment diff --git a/src/leapflow/skills/discovery.py b/src/leapflow/skills/discovery.py index 6380540c..54d9a1a0 100644 --- a/src/leapflow/skills/discovery.py +++ b/src/leapflow/skills/discovery.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skill discovery tools — exposed to LLM for progressive disclosure. Provides two tool handlers registered into the unified tool system: diff --git a/src/leapflow/skills/evolution.py b/src/leapflow/skills/evolution.py index bc490f6c..87d40b08 100644 --- a/src/leapflow/skills/evolution.py +++ b/src/leapflow/skills/evolution.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skill evolution policy — manages confidence/version progression and degradation. Implements the trust gradient: DRAFT → CANDIDATE → VERIFIED → PRODUCTION diff --git a/src/leapflow/skills/index.py b/src/leapflow/skills/index.py index 582c95e0..0ecb8bb6 100644 --- a/src/leapflow/skills/index.py +++ b/src/leapflow/skills/index.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skill index with three-layer caching and conditional filtering. Hermes-inspired design: skills are discovered from SKILL.md files, diff --git a/src/leapflow/skills/injector.py b/src/leapflow/skills/injector.py index cc098a43..38fbb903 100644 --- a/src/leapflow/skills/injector.py +++ b/src/leapflow/skills/injector.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Skill content injection as user message (Hermes pattern). Protects system prompt cache by injecting SKILL.md content into diff --git a/src/leapflow/skills/registry.py b/src/leapflow/skills/registry.py index 974e7b07..05117e62 100644 --- a/src/leapflow/skills/registry.py +++ b/src/leapflow/skills/registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Parameterized skill registry with validation, metadata, and trigger matching.""" from __future__ import annotations diff --git a/src/leapflow/skills/sandbox.py b/src/leapflow/skills/sandbox.py index 9300dd3b..c9a42ad8 100644 --- a/src/leapflow/skills/sandbox.py +++ b/src/leapflow/skills/sandbox.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Sandboxed execution namespace for distilled skill code. Restricts the runtime environment of exec()'d skill code to prevent diff --git a/src/leapflow/skills/semantic_adapter.py b/src/leapflow/skills/semantic_adapter.py index 1b2fbd15..55d1719a 100644 --- a/src/leapflow/skills/semantic_adapter.py +++ b/src/leapflow/skills/semantic_adapter.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Semantic Adapter — translation layer between LLM tools and platform ports. This is the execution-side counterpart to the Recording pipeline's diff --git a/src/leapflow/skills/semantic_schema.py b/src/leapflow/skills/semantic_schema.py index a667c369..3f7b6a1e 100644 --- a/src/leapflow/skills/semantic_schema.py +++ b/src/leapflow/skills/semantic_schema.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Semantic tool schema support — metadata and conversion for desktop tools. Semantic desktop tools (observe_ui, click, switch_app, ...) are registered by diff --git a/src/leapflow/skills/tool_executor.py b/src/leapflow/skills/tool_executor.py index 86dd9131..c3d010ed 100644 --- a/src/leapflow/skills/tool_executor.py +++ b/src/leapflow/skills/tool_executor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """ReAct-style tool-use executor for SKILL.md skills. Gives the LLM access to real system tools (file ops, shell, UI) via diff --git a/src/leapflow/storage/__init__.py b/src/leapflow/storage/__init__.py index b7bd8546..e43e173a 100644 --- a/src/leapflow/storage/__init__.py +++ b/src/leapflow/storage/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Persistence layer — trajectory, skill library, session, conversation, and document stores. Key infrastructure: diff --git a/src/leapflow/storage/bundle_writer.py b/src/leapflow/storage/bundle_writer.py index 75a8dde1..c809ac00 100644 --- a/src/leapflow/storage/bundle_writer.py +++ b/src/leapflow/storage/bundle_writer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Bundle file I/O — writes auxiliary Knowledge Bundle artifacts alongside SKILL.md. Each method creates parent directories as needed. All writes are idempotent. diff --git a/src/leapflow/storage/capability_observation_store.py b/src/leapflow/storage/capability_observation_store.py index 5f2bea9d..79bcb0b0 100644 --- a/src/leapflow/storage/capability_observation_store.py +++ b/src/leapflow/storage/capability_observation_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Durable store for structured capability observations. The store is profile-scoped and intentionally stores only structured metadata @@ -27,6 +28,20 @@ "failure_code", "capability", "tool_name", + # Who is failing, and how persistently. Degradation evidence reports that an + # *existing* provider is inadequate, so the provider's identity is the payload + # -- dropping it leaves "some capability is degraded" with no way to name the + # incumbent. Two consumers need it: the teacher, which cannot judge a + # replacement without knowing what would be replaced, and proposal identity, + # which derives a rival's plugin id from the incumbent so the two can coexist + # rather than collide on one capability-derived name. + "plugin_id", + "failure_streak", + "trust_level", + # What *kind* of failure it was. Without it a degradation fact reads "failed + # twice" and the retry-owned classes cannot be filtered out, so a timeout would + # reach the teacher and the only verdict that changes anything is "rebuild". + "failure_class", # Declarations the detector needs to rebuild a requirement from a # persisted observation. Dropping these silently changed behaviour rather # than failing: without ``max_risk_level`` the requirement inherited the diff --git a/src/leapflow/storage/capability_plan_store.py b/src/leapflow/storage/capability_plan_store.py index 41654dc7..166c80e6 100644 --- a/src/leapflow/storage/capability_plan_store.py +++ b/src/leapflow/storage/capability_plan_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """JSON store for adaptive capability decision history. The store persists transparent resolver output for user review and dashboard / diff --git a/src/leapflow/storage/capability_proposal_queue.py b/src/leapflow/storage/capability_proposal_queue.py index d3bf3703..d8f3d28f 100644 --- a/src/leapflow/storage/capability_proposal_queue.py +++ b/src/leapflow/storage/capability_proposal_queue.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Durable capability proposal queue for adaptive plugin evolution.""" from __future__ import annotations diff --git a/src/leapflow/storage/connection.py b/src/leapflow/storage/connection.py index 22b99bd5..9af4dda4 100644 --- a/src/leapflow/storage/connection.py +++ b/src/leapflow/storage/connection.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """ConnectionHolder protocol and implementation for shared DuckDB access. All stores receive a ``ConnectionHolder`` instead of a raw ``db_path``. diff --git a/src/leapflow/storage/conversation_store.py b/src/leapflow/storage/conversation_store.py index 5551827f..d8095c6f 100644 --- a/src/leapflow/storage/conversation_store.py +++ b/src/leapflow/storage/conversation_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Conversation session persistence — chat history storage with full-text search. Design (inspired by hermes hermes_state.py): diff --git a/src/leapflow/storage/db_repair.py b/src/leapflow/storage/db_repair.py index bc33aed5..22d97099 100644 --- a/src/leapflow/storage/db_repair.py +++ b/src/leapflow/storage/db_repair.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DuckDB health check and self-repair — corrupt detection + automatic backup. Provides: diff --git a/src/leapflow/storage/distilled_knowledge_store.py b/src/leapflow/storage/distilled_knowledge_store.py new file mode 100644 index 00000000..1b041964 --- /dev/null +++ b/src/leapflow/storage/distilled_knowledge_store.py @@ -0,0 +1,317 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Durable home for what the teacher distilled, and the rules that retire it. + +This is the C1 channel: the cheapest way the system adapts to a changed environment. +Three of the four adaptation actions change nothing except what the acting agent knows, +so a statement like "the send control is now labelled Dispatch and lives in the toolbar" +lets the next session succeed with no code written, no approval, and no trust rebuilt. + +**Retirement is designed in, not bolted on.** An assertion about a world that keeps +changing is only true for a while, and stale knowledge does not merely go unused -- it +actively misleads, because the acting agent has no way to tell a current fact from one +that expired three upgrades ago. Telling it "the send control is labelled Dispatch" after +the control was renamed again is worse than telling it nothing. So an entry leaves in +exactly three ways: + +* **Superseded** -- a newer verdict about the same capability replaces the older one. + One live entry per capability, because the teacher's latest conclusion is its + conclusion; keeping the history live would present the agent with a capability's + contradictory past as though every version were current. +* **Expired** -- entries have a bounded lifetime, configurable rather than fixed. + Unbounded accumulation would eventually dominate the context it was meant to improve. +* **Retracted** -- an explicit call, used when a capability is observed working again and + the knowledge describing its failure is therefore obsolete. + +What is deliberately *not* a retirement rule: an environment fingerprint that no longer +matches the current one. It is recorded and disclosed, never used to filter. Whether an +OS point release invalidates "the send control is labelled Dispatch" is a judgement about +meaning, and the storage layer guessing it would be a hard rule with no ability to +generalise -- precisely the kind that looks safe and quietly discards good knowledge. The +mismatch is surfaced so the reader can weigh it; the reader is a language model, and this +is the sort of thing it is better at than a predicate. +""" + +from __future__ import annotations + +import json +import logging +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +logger = logging.getLogger(__name__) + +#: Fields persisted for one entry. An explicit allow-list, matching the observation +#: store's convention -- and carrying its scar: a field absent from that store's list was +#: silently dropped, so a fact reached the teacher with ``None`` where an identity should +#: have been and nothing raised. Adding a field to the record means adding it here. +#: +#: Bound to ``DistilledKnowledge.to_dict()`` *exactly*, not loosely, and a test asserts +#: it. A field the dataclass has and this set lacks is the historical silent drop; an +#: entry here with no matching field is the mirror image -- it claims to persist something +#: that never existed, which is how a whitelist stops being trustworthy. ``"environment"`` +#: was exactly that: a leftover from considering whether to store the whole fingerprint. +_ENTRY_FIELDS: frozenset[str] = frozenset( + { + "capability", + "knowledge", + "action", + "verdict_id", + "confidence", + "rationale", + "target", + "environment_id", + "created_at", + } +) + + +@dataclass(frozen=True) +class DistilledKnowledge: + """One thing the teacher concluded is true about the environment.""" + + capability: str + knowledge: str + action: str = "" + verdict_id: str = "" + confidence: float = 0.0 + rationale: str = "" + target: str = "" + #: The environment this was learned in. Disclosed to the reader, never used to + #: filter: see the module docstring. + environment_id: str = "" + created_at: float = 0.0 + + @classmethod + def from_verdict( + cls, verdict: Any, *, environment: Mapping[str, Any] | None = None + ) -> DistilledKnowledge: + """Project an ``AdaptationVerdict`` into a storable entry. + + Takes the verdict duck-typed rather than imported, so storage does not depend on + the domain module that depends on it. + """ + env = dict(environment or {}) + return cls( + capability=str(getattr(verdict, "capability", "") or ""), + knowledge=str(getattr(verdict, "knowledge", "") or ""), + action=str(getattr(verdict, "action", "") or ""), + verdict_id=str(getattr(verdict, "verdict_id", "") or ""), + confidence=float(getattr(verdict, "confidence", 0.0) or 0.0), + rationale=str(getattr(verdict, "rationale", "") or ""), + target=str(getattr(verdict, "target", "") or ""), + environment_id=str(env.get("fingerprint_id") or ""), + created_at=float(getattr(verdict, "created_at", 0.0) or time.time()), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "capability": self.capability, + "knowledge": self.knowledge, + "action": self.action, + "verdict_id": self.verdict_id, + "confidence": self.confidence, + "rationale": self.rationale, + "target": self.target, + "environment_id": self.environment_id, + "created_at": self.created_at, + } + + @classmethod + def from_dict(cls, payload: Mapping[str, Any]) -> DistilledKnowledge: + return cls( + capability=str(payload.get("capability") or ""), + knowledge=str(payload.get("knowledge") or ""), + action=str(payload.get("action") or ""), + verdict_id=str(payload.get("verdict_id") or ""), + confidence=float(payload.get("confidence") or 0.0), + rationale=str(payload.get("rationale") or ""), + target=str(payload.get("target") or ""), + environment_id=str(payload.get("environment_id") or ""), + created_at=float(payload.get("created_at") or 0.0), + ) + + +class JsonDistilledKnowledgeStore: + """Profile-scoped store for distilled knowledge, keyed by capability. + + JSON rather than the semantic memory provider, for a reason that decided the design: + the reader needs *every* live entry, and semantic memory answers keyword queries. A + fact the agent needs is not necessarily a fact whose words appear in the request -- + "the send control is now Dispatch" is exactly what a request saying "reply to Ana" + needs and would never retrieve. Complete enumeration is the requirement, so the store + that offers it is the right one. + + Reads are hot (every turn that discloses knowledge) and writes are cold (once per + session, at grading time), which is why an entry cache is kept and invalidated on + write rather than re-reading the file per turn. + """ + + def __init__(self, path: Path, *, ttl_seconds: float = 0.0) -> None: + self._path = Path(path) + self._ttl = max(0.0, float(ttl_seconds)) + self._cache: tuple[DistilledKnowledge, ...] | None = None + + @property + def path(self) -> Path: + return self._path + + @property + def ttl_seconds(self) -> float: + return self._ttl + + # ── writes (cold path) ──────────────────────────────────────────────────── + + def record( + self, verdict: Any, *, environment: Mapping[str, Any] | None = None + ) -> DistilledKnowledge | None: + """Store one verdict's knowledge, superseding any earlier entry for it. + + Returns the stored entry, or ``None`` when the verdict carries nothing usable. + Refusing silently here would be wrong in the other direction: a verdict without + knowledge is a defect in the parser, which already rejects that shape, so + reaching this point means something upstream changed. + """ + entry = DistilledKnowledge.from_verdict(verdict, environment=environment) + if not entry.capability or not entry.knowledge: + logger.debug( + "distilled_knowledge: refused entry without capability or knowledge (%r)", + entry.verdict_id, + ) + return None + # Supersession, not append: the teacher's latest conclusion about a capability is + # its conclusion, and keeping the older one live would show the agent a + # capability's contradictory past as though every version were current. + kept = [e for e in self._load() if e.capability != entry.capability] + kept.append(entry) + self._write(kept) + return entry + + def record_all( + self, verdicts: Any, *, environment: Mapping[str, Any] | None = None + ) -> tuple[DistilledKnowledge, ...]: + """Store a batch, one write for the lot. + + Later verdicts about the same capability win, matching ``record``'s supersession + within the batch as well as across batches. + """ + incoming: dict[str, DistilledKnowledge] = {} + for verdict in verdicts or (): + entry = DistilledKnowledge.from_verdict(verdict, environment=environment) + if entry.capability and entry.knowledge: + incoming[entry.capability] = entry + if not incoming: + return () + kept = [e for e in self._load() if e.capability not in incoming] + kept.extend(incoming.values()) + self._write(kept) + return tuple(incoming.values()) + + def retract(self, capability: str, *, reason: str = "") -> bool: + """Drop the entry for one capability. Used when its knowledge is obsolete. + + The third retirement path, and the only one a caller drives: a capability + observed working again makes knowledge describing its failure misleading, and + nothing about supersession or expiry would remove it -- no newer verdict is + coming precisely because there is no longer anything wrong. + """ + name = str(capability or "").strip() + if not name: + return False + entries = self._load() + kept = [e for e in entries if e.capability != name] + if len(kept) == len(entries): + return False + logger.debug( + "distilled_knowledge: retracted %s (%s)", name, reason or "no reason given" + ) + self._write(kept) + return True + + # ── reads (hot path) ────────────────────────────────────────────────────── + + def live(self, *, now: float | None = None) -> tuple[DistilledKnowledge, ...]: + """Every entry still considered true, newest first. + + Expiry is applied on read rather than by a sweep, so a stale entry cannot be + disclosed just because no write happened to trigger a cleanup. + """ + entries = self._load() + if self._ttl > 0.0: + cutoff = (time.time() if now is None else now) - self._ttl + entries = [e for e in entries if e.created_at >= cutoff] + return tuple(sorted(entries, key=lambda e: e.created_at, reverse=True)) + + def for_capability(self, capability: str) -> DistilledKnowledge | None: + """The live entry for one capability, if any.""" + name = str(capability or "").strip() + return next((e for e in self.live() if e.capability == name), None) + + def rebind_preferences(self) -> tuple[tuple[str, str], ...]: + """``(capability, preferred provider)`` for every live ``rebind`` entry. + + Only ``rebind``, because only that action names a provider that should be chosen. + An ``escalate`` target names what a *person* must do and an ``absorb`` has no + target at all, so admitting them would turn an instruction to a human into a + selection preference. + + Expiry and retraction apply, so a preference stops being read when the knowledge + behind it stops being true -- there is nothing to unlearn. + """ + return tuple( + (entry.capability, entry.target) + for entry in self.live() + if entry.action == "rebind" and entry.target + ) + + def count(self) -> int: + return len(self.live()) + + # ── persistence ─────────────────────────────────────────────────────────── + + def _load(self) -> list[DistilledKnowledge]: + if self._cache is not None: + return list(self._cache) + entries: list[DistilledKnowledge] = [] + if self._path.exists(): + try: + payload = json.loads(self._path.read_text(encoding="utf-8") or "{}") + for raw in payload.get("entries", []) or (): + if isinstance(raw, Mapping): + entries.append(DistilledKnowledge.from_dict(raw)) + except (OSError, ValueError, TypeError): + # A corrupt file must not break a turn. Distilled knowledge is an + # improvement to context, so its absence degrades quality rather than + # correctness -- exactly the case for starting empty over raising. + logger.debug( + "distilled_knowledge: unreadable store at %s", self._path, exc_info=True + ) + entries = [] + self._cache = tuple(entries) + return list(entries) + + def _write(self, entries: list[DistilledKnowledge]) -> None: + self._cache = tuple(entries) + payload = { + "version": 1, + "entries": [ + {k: v for k, v in e.to_dict().items() if k in _ENTRY_FIELDS} + for e in entries + ], + } + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + tmp = self._path.with_suffix(self._path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8") + tmp.replace(self._path) + except OSError: + logger.debug( + "distilled_knowledge: could not persist to %s", self._path, exc_info=True + ) + + +__all__ = [ + "DistilledKnowledge", + "JsonDistilledKnowledgeStore", +] diff --git a/src/leapflow/storage/duckdb_connect.py b/src/leapflow/storage/duckdb_connect.py index c61cd570..5722e27b 100644 --- a/src/leapflow/storage/duckdb_connect.py +++ b/src/leapflow/storage/duckdb_connect.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Centralized DuckDB connection factory with lock detection and repair. Replaces bare ``duckdb.connect()`` calls throughout the codebase with a diff --git a/src/leapflow/storage/evolution_store.py b/src/leapflow/storage/evolution_store.py index 62bb507c..a06d5125 100644 --- a/src/leapflow/storage/evolution_store.py +++ b/src/leapflow/storage/evolution_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DuckDB persistence for EvolutionMemoryProvider — skill episodes survive restart. Design: diff --git a/src/leapflow/storage/evolution_trace_store.py b/src/leapflow/storage/evolution_trace_store.py index acd8659e..c2396fe3 100644 --- a/src/leapflow/storage/evolution_trace_store.py +++ b/src/leapflow/storage/evolution_trace_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Durable store for framework-evolution traces. ⚠️ Not to be confused with :mod:`leapflow.storage.evolution_store`, whose diff --git a/src/leapflow/storage/plugin_outcome_store.py b/src/leapflow/storage/plugin_outcome_store.py index 318f61d6..a78e3f07 100644 --- a/src/leapflow/storage/plugin_outcome_store.py +++ b/src/leapflow/storage/plugin_outcome_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Profile-scoped audit store for adaptive plugin execution outcomes.""" from __future__ import annotations diff --git a/src/leapflow/storage/plugin_proposal_store.py b/src/leapflow/storage/plugin_proposal_store.py index c7fc49d2..e0265b97 100644 --- a/src/leapflow/storage/plugin_proposal_store.py +++ b/src/leapflow/storage/plugin_proposal_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Profile-scoped JSON store for plugin proposals. The store intentionally uses the path supplied by ProfileLayout diff --git a/src/leapflow/storage/plugin_version_store.py b/src/leapflow/storage/plugin_version_store.py index 4131c9b3..7d37552b 100644 --- a/src/leapflow/storage/plugin_version_store.py +++ b/src/leapflow/storage/plugin_version_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Profile-scoped version store for dynamically installed plugins.""" from __future__ import annotations diff --git a/src/leapflow/storage/reentry_store.py b/src/leapflow/storage/reentry_store.py index 6e9b6f5f..617d934d 100644 --- a/src/leapflow/storage/reentry_store.py +++ b/src/leapflow/storage/reentry_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DuckDB-backed persistence for event-driven re-entry (S2, phase N1). Enables "finalize + Orient-seeded re-entry": a task can finalize a turn while diff --git a/src/leapflow/storage/research_ledger_store.py b/src/leapflow/storage/research_ledger_store.py index 59dab3ae..24a38dcb 100644 --- a/src/leapflow/storage/research_ledger_store.py +++ b/src/leapflow/storage/research_ledger_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DuckDB-backed persistence for the per-session research ledger (S1). Durable Orient: the structured long-task state (findings, open questions, diff --git a/src/leapflow/storage/schema.py b/src/leapflow/storage/schema.py index ffb877c0..3f5e022f 100644 --- a/src/leapflow/storage/schema.py +++ b/src/leapflow/storage/schema.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unified DuckDB schema definition and migration for leap.duckdb. Single source of truth for all table schemas. Each store registers its diff --git a/src/leapflow/storage/session_store.py b/src/leapflow/storage/session_store.py index 4d75fce2..989fec6a 100644 --- a/src/leapflow/storage/session_store.py +++ b/src/leapflow/storage/session_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Persistent store for learning session metadata. Enables `leap teach --resume` by persisting LearningSession records across diff --git a/src/leapflow/storage/skill_docs.py b/src/leapflow/storage/skill_docs.py index 37041b01..741bc62f 100644 --- a/src/leapflow/storage/skill_docs.py +++ b/src/leapflow/storage/skill_docs.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Filesystem-based Skill Document store. Manages skill-name/SKILL.md folder structure on disk and provides diff --git a/src/leapflow/storage/skill_library.py b/src/leapflow/storage/skill_library.py index b1b58b9c..ca527dad 100644 --- a/src/leapflow/storage/skill_library.py +++ b/src/leapflow/storage/skill_library.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Persistent skill library and update suggestion store. Stores distilled skills durably so the active learning system can compare diff --git a/src/leapflow/storage/trajectory_store.py b/src/leapflow/storage/trajectory_store.py index 632544f3..70bf935c 100644 --- a/src/leapflow/storage/trajectory_store.py +++ b/src/leapflow/storage/trajectory_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """DuckDB-backed trajectory persistence. Follows the same patterns as memory/long_term.py: single DuckDB connection, diff --git a/src/leapflow/storage/write_buffer.py b/src/leapflow/storage/write_buffer.py index 63062703..d2247730 100644 --- a/src/leapflow/storage/write_buffer.py +++ b/src/leapflow/storage/write_buffer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Batched write buffer for DuckDB stores. High-frequency signal writes to DuckDB benefit from batching: diff --git a/src/leapflow/telemetry/__init__.py b/src/leapflow/telemetry/__init__.py index e21553f4..71537806 100644 --- a/src/leapflow/telemetry/__init__.py +++ b/src/leapflow/telemetry/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Telemetry taps: optional, opt-in observation points for runtime facts. A tap is a module-level sink plus a one-line emit function. Absent a sink every diff --git a/src/leapflow/telemetry/evolution_tap.py b/src/leapflow/telemetry/evolution_tap.py index 1cb53ee1..6e76fbc8 100644 --- a/src/leapflow/telemetry/evolution_tap.py +++ b/src/leapflow/telemetry/evolution_tap.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """EvolutionTap: emit a framework-evolution fact, or do nothing at all. One module-level optional sink, and one function that writes to it. When no sink diff --git a/src/leapflow/tools/__init__.py b/src/leapflow/tools/__init__.py index 73a6e218..7930688d 100644 --- a/src/leapflow/tools/__init__.py +++ b/src/leapflow/tools/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tool implementations — the callable behaviour behind the agent's tools. This package holds what tools *do* (file operations, shell, terminal sessions, diff --git a/src/leapflow/tools/code_intel.py b/src/leapflow/tools/code_intel.py index 2f59590a..f1c27077 100644 --- a/src/leapflow/tools/code_intel.py +++ b/src/leapflow/tools/code_intel.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Precise code intelligence for the agent loop. Currently provides ``symbols`` (document outline): for Python files an ``ast`` diff --git a/src/leapflow/tools/config_tools.py b/src/leapflow/tools/config_tools.py index 86100b13..966bf111 100644 --- a/src/leapflow/tools/config_tools.py +++ b/src/leapflow/tools/config_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Config tools: let the model read and change settings without touching paths. Without these, a request like "switch the model to X" has no legal path: the @@ -20,6 +21,8 @@ import logging from typing import Any, Dict +from leapflow.learning.capability_effect_verifier import declare_effect + logger = logging.getLogger(__name__) # A listing of every writable field is long; keep the default bounded and let the @@ -287,6 +290,18 @@ async def config_set_handler(args: Dict[str, Any]) -> Dict[str, Any]: # Never echo a credential back into the transcript. if not before.secret: payload["value"] = args["value"] + if result.ok: + # The effect declaration follows the same redaction rule as the payload: a + # secret's new value must not travel here either, so its effect names the key + # and scope only. Both forms are still observations -- the write returned ok + # and the key now holds what was set. + payload.update( + declare_effect( + f"config key {key} in scope {scope} is now {args['value']}" + if not before.secret + else f"config key {key} in scope {scope} was updated" + ) + ) if before.hot_reload == "restart-required": payload["restart_required"] = True payload["next_step"] = "Run `leap daemon restart` for this change to take effect." diff --git a/src/leapflow/tools/dev_tools.py b/src/leapflow/tools/dev_tools.py index afd019f5..2b458f4e 100644 --- a/src/leapflow/tools/dev_tools.py +++ b/src/leapflow/tools/dev_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Structured developer verification tools: test_run and lint_check. Both are thin, structured wrappers over ``shell_run``: they auto-detect (or take diff --git a/src/leapflow/tools/execution_context.py b/src/leapflow/tools/execution_context.py index 8a939fba..b7ba3e9b 100644 --- a/src/leapflow/tools/execution_context.py +++ b/src/leapflow/tools/execution_context.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Per-turn tool execution context for workspace-scoped safety. Daemon-backed turns from different TUI clients may share one Python process but diff --git a/src/leapflow/tools/file_operations.py b/src/leapflow/tools/file_operations.py index 81051f4f..31c5ce3a 100644 --- a/src/leapflow/tools/file_operations.py +++ b/src/leapflow/tools/file_operations.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """File system operations — list, read, write. All handlers follow the unified tool convention: receive params dict, return result dict. @@ -24,6 +25,7 @@ from pathlib import Path from typing import Any, Dict, Iterable, List, Tuple +from leapflow.learning.capability_effect_verifier import declare_effect from leapflow.security.path_sensitivity import PathSensitivity, classify_path_sensitivity from leapflow.tools.execution_context import require_workspace_access, resolve_workspace_path @@ -483,11 +485,19 @@ async def file_write(params: Dict[str, Any]) -> Dict[str, Any]: else: target.write_text(content) syntax = _verify_syntax(target, content) if mode != "append" else {} + written = len(content.encode()) return { "ok": True, "path": str(target), - "bytes_written": len(content.encode()), + "bytes_written": written, **syntax, + # Measured after the write, in the terms a requirement would state it. + # The byte count comes from the content that actually reached the file, + # so this is an observation rather than a restatement of the request. + **declare_effect( + f"{'appended' if mode == 'append' else 'wrote'} {written} bytes " + f"to {target.name}" + ), **_sensitivity_metadata(sensitivity), } except Exception as e: @@ -963,4 +973,8 @@ async def edit_file(params: Dict[str, Any]) -> Dict[str, Any]: "bytes_written": len(content.encode()), **_verify_syntax(target, content), **_sensitivity_metadata(sensitivity), + **declare_effect( + f"applied {len(edits)} edit(s) making {total_replacements} " + f"replacement(s) in {target.name}" + ), } diff --git a/src/leapflow/tools/gateway_tool.py b/src/leapflow/tools/gateway_tool.py index 3d2dd4f2..4d348f18 100644 --- a/src/leapflow/tools/gateway_tool.py +++ b/src/leapflow/tools/gateway_tool.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Gateway tools for the agent — configuration AND messaging. Two tools: diff --git a/src/leapflow/tools/hub_tool.py b/src/leapflow/tools/hub_tool.py index 5a85b86f..50c334f1 100644 --- a/src/leapflow/tools/hub_tool.py +++ b/src/leapflow/tools/hub_tool.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hub operations as an Agent Tool — enables natural language hub interaction. Registered as agent-callable tools so the AgentEngine can push, pull, search, diff --git a/src/leapflow/tools/name_resolver.py b/src/leapflow/tools/name_resolver.py index 0c5fb59f..c0ec98d7 100644 --- a/src/leapflow/tools/name_resolver.py +++ b/src/leapflow/tools/name_resolver.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tool registry and name-resolution primitives. This module centralizes the Tool Capability Contract: the set of canonical diff --git a/src/leapflow/tools/repo_map.py b/src/leapflow/tools/repo_map.py index 8a2841b6..4949b0b5 100644 --- a/src/leapflow/tools/repo_map.py +++ b/src/leapflow/tools/repo_map.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Repository orientation map — a compact, read-only project overview. Grounds the agent when it enters a codebase: languages, detected test/lint diff --git a/src/leapflow/tools/scm_tools.py b/src/leapflow/tools/scm_tools.py index 6ab359f3..9d88e53f 100644 --- a/src/leapflow/tools/scm_tools.py +++ b/src/leapflow/tools/scm_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Typed source-control tools. The SCM tool intentionally models git operations as structured actions instead diff --git a/src/leapflow/tools/shell_tools.py b/src/leapflow/tools/shell_tools.py index f945593c..6d563dd1 100644 --- a/src/leapflow/tools/shell_tools.py +++ b/src/leapflow/tools/shell_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shell command execution with timeout, safety, and output redaction. All handlers follow the unified tool convention: receive params dict, return result dict. diff --git a/src/leapflow/tools/system_tools.py b/src/leapflow/tools/system_tools.py index edb906da..3c866aca 100644 --- a/src/leapflow/tools/system_tools.py +++ b/src/leapflow/tools/system_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """System utilities — time, environment info. All handlers follow the unified tool convention: receive params dict, return result dict. diff --git a/src/leapflow/tools/terminal_session.py b/src/leapflow/tools/terminal_session.py index ff68e0c5..67f47fc8 100644 --- a/src/leapflow/tools/terminal_session.py +++ b/src/leapflow/tools/terminal_session.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Persistent terminal sessions — long-lived, opt-in, DISABLED by default. Long-lived interactive shells (REPLs, dev servers, watch loops) hold process / diff --git a/src/leapflow/tools/text_tools.py b/src/leapflow/tools/text_tools.py index fa92bda9..37ac8359 100644 --- a/src/leapflow/tools/text_tools.py +++ b/src/leapflow/tools/text_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Text processing utilities — search, replace. All handlers follow the unified tool convention: receive params dict, return result dict. diff --git a/src/leapflow/tools/web_cache.py b/src/leapflow/tools/web_cache.py index 979a2340..03bdef58 100644 --- a/src/leapflow/tools/web_cache.py +++ b/src/leapflow/tools/web_cache.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Session-scoped body cache for ``web_fetch``. Split out of the tool because storage is a separate responsibility from transport diff --git a/src/leapflow/tools/web_extract.py b/src/leapflow/tools/web_extract.py index 6afe4965..100a80aa 100644 --- a/src/leapflow/tools/web_extract.py +++ b/src/leapflow/tools/web_extract.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Content extraction for fetched web responses. A fetch tool that hands raw markup to the model is not usable: a single page can diff --git a/src/leapflow/tools/web_fetch.py b/src/leapflow/tools/web_fetch.py index b679ba98..70274111 100644 --- a/src/leapflow/tools/web_fetch.py +++ b/src/leapflow/tools/web_fetch.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """web_fetch — first-class read-only HTTP access for the agent loop. Without this tool the only way to read a URL is ``shell_run`` with a hand-written diff --git a/src/leapflow/utils/__init__.py b/src/leapflow/utils/__init__.py index 9265ad8f..9086b03c 100644 --- a/src/leapflow/utils/__init__.py +++ b/src/leapflow/utils/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shared utilities — cross-cutting infrastructure used across multiple modules.""" from leapflow.utils.diagnostics import PipelineTracer, StageRecord diff --git a/src/leapflow/utils/build_info.py b/src/leapflow/utils/build_info.py index 7faa15f2..49c1a15b 100644 --- a/src/leapflow/utils/build_info.py +++ b/src/leapflow/utils/build_info.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Best-effort source-tree fingerprint for long-lived-process staleness checks. Long-lived local processes (the ``leapd`` daemon, the LeapBoard web server) diff --git a/src/leapflow/utils/diagnostics.py b/src/leapflow/utils/diagnostics.py index dfb7a5b7..97cd0b23 100644 --- a/src/leapflow/utils/diagnostics.py +++ b/src/leapflow/utils/diagnostics.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Lightweight pipeline tracing and structured diagnostics. Provides non-intrusive instrumentation for multi-stage processing pipelines. diff --git a/src/leapflow/utils/file_lock.py b/src/leapflow/utils/file_lock.py index 50e71bfc..bfcc2c2e 100644 --- a/src/leapflow/utils/file_lock.py +++ b/src/leapflow/utils/file_lock.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cross-platform exclusive advisory locking on open files. POSIX systems provide ``fcntl.flock`` (advisory, whole-file, released when diff --git a/src/leapflow/utils/process_group.py b/src/leapflow/utils/process_group.py index 69c97cd5..44da3d9b 100644 --- a/src/leapflow/utils/process_group.py +++ b/src/leapflow/utils/process_group.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cross-platform process-group termination. POSIX process groups and Windows job objects play the same role: a diff --git a/src/leapflow/utils/progress.py b/src/leapflow/utils/progress.py index 6cbb7b24..f6ccbd9f 100644 --- a/src/leapflow/utils/progress.py +++ b/src/leapflow/utils/progress.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Verbose progress reporters for multi-stage pipelines (CLI-friendly). Originally extracted from ``leapflow.cli.helpers``. These reporters print diff --git a/src/leapflow/utils/resilience.py b/src/leapflow/utils/resilience.py index fd23181c..64d3c8b7 100644 --- a/src/leapflow/utils/resilience.py +++ b/src/leapflow/utils/resilience.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Universal async resilience: timeout + retry with exponential backoff. Provides a composable execution wrapper usable across all execution paths diff --git a/src/leapflow/utils/shell_lex.py b/src/leapflow/utils/shell_lex.py index d8b0dc74..30d0faf1 100644 --- a/src/leapflow/utils/shell_lex.py +++ b/src/leapflow/utils/shell_lex.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shell-style argument tokenization that survives Windows paths.""" from __future__ import annotations diff --git a/src/leapflow/utils/stream_progress.py b/src/leapflow/utils/stream_progress.py index f434e3b4..01ad8644 100644 --- a/src/leapflow/utils/stream_progress.py +++ b/src/leapflow/utils/stream_progress.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Terminal stream progress writer for LLM chunk callbacks. Renders LLM streaming output as dim gray text on stdout, giving the user diff --git a/src/leapflow/utils/terminal_io.py b/src/leapflow/utils/terminal_io.py index 881ef5fc..a489a459 100644 --- a/src/leapflow/utils/terminal_io.py +++ b/src/leapflow/utils/terminal_io.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Terminal-based IOProvider for interactive CLI confirmation.""" from __future__ import annotations diff --git a/src/leapflow/version.py b/src/leapflow/version.py index bb6160a3..1a745016 100644 --- a/src/leapflow/version.py +++ b/src/leapflow/version.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Version information for leapflow.""" __version__ = "0.2.0+main" diff --git a/src/leapflow/world_model/__init__.py b/src/leapflow/world_model/__init__.py index b552a19f..7ccd909f 100644 --- a/src/leapflow/world_model/__init__.py +++ b/src/leapflow/world_model/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """World Model — train-free curiosity-driven predictive learning. Provides the Predict → Execute → Compare → Learn loop, diff --git a/src/leapflow/world_model/_json_utils.py b/src/leapflow/world_model/_json_utils.py index 3ac028ff..a14c5cb9 100644 --- a/src/leapflow/world_model/_json_utils.py +++ b/src/leapflow/world_model/_json_utils.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shared JSON extraction for world model modules.""" from __future__ import annotations diff --git a/src/leapflow/world_model/budget.py b/src/leapflow/world_model/budget.py index 13122ee6..fda444a0 100644 --- a/src/leapflow/world_model/budget.py +++ b/src/leapflow/world_model/budget.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Token-bucket based budget controller for world model learning operations. Manages compute budgets for prediction, comparison, and replay calls diff --git a/src/leapflow/world_model/curiosity.py b/src/leapflow/world_model/curiosity.py index 5c97a9dc..f86d4f72 100644 --- a/src/leapflow/world_model/curiosity.py +++ b/src/leapflow/world_model/curiosity.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Curiosity signal — composite intrinsic motivation for exploration. Unifies three intrinsic motivation components from RL literature diff --git a/src/leapflow/world_model/embedding.py b/src/leapflow/world_model/embedding.py index ca97bce8..96822b48 100644 --- a/src/leapflow/world_model/embedding.py +++ b/src/leapflow/world_model/embedding.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Embedding providers for semantic similarity in experience retrieval. Abstracts the embedding source behind a Protocol so callers don't couple diff --git a/src/leapflow/world_model/experience_store.py b/src/leapflow/world_model/experience_store.py index 773625c2..5c39c0de 100644 --- a/src/leapflow/world_model/experience_store.py +++ b/src/leapflow/world_model/experience_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Experience storage backed by SemanticMemoryProvider. Stores (state, action, prediction, actual_effect, δ) tuples as a dedicated diff --git a/src/leapflow/world_model/orientation.py b/src/leapflow/world_model/orientation.py index 26f8440e..a878930d 100644 --- a/src/leapflow/world_model/orientation.py +++ b/src/leapflow/world_model/orientation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """S4-D1: multi-layer orientation aggregation (observe-only). A unified, read-only "orientation" query that merges the agent's orientation diff --git a/src/leapflow/world_model/prediction.py b/src/leapflow/world_model/prediction.py index 4e00c245..6378fa58 100644 --- a/src/leapflow/world_model/prediction.py +++ b/src/leapflow/world_model/prediction.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Prediction Loop — the core Predict → Execute → Compare → Learn cycle. Implements on-policy predictive coding: before each action execution, diff --git a/src/leapflow/world_model/replay.py b/src/leapflow/world_model/replay.py index 7882c950..0ccb1389 100644 --- a/src/leapflow/world_model/replay.py +++ b/src/leapflow/world_model/replay.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Experience Replay Engine — off-policy learning from historical experiences. Discovers cross-time, cross-application patterns by reflecting on stored diff --git a/src/leapflow/world_model/trajectory_grader.py b/src/leapflow/world_model/trajectory_grader.py index 5dac7122..b4fc4f55 100644 --- a/src/leapflow/world_model/trajectory_grader.py +++ b/src/leapflow/world_model/trajectory_grader.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Trajectory Grader — OPD teacher role for train-free agentic learning. Implements the "teacher-as-reward-model" pattern from On-Policy Distillation: @@ -13,15 +14,15 @@ from __future__ import annotations import logging -import re from dataclasses import dataclass -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, Any, List, Mapping, Sequence if TYPE_CHECKING: from leapflow.world_model.budget import LearningBudgetController from leapflow.world_model.experience_store import ExperienceStore -from leapflow.domain.evolution_intent import EvolutionIntent +from leapflow.domain.adaptation_verdict import AdaptationVerdict +from leapflow.domain.evolution_intent import EvolutionIntent, is_capability_name from leapflow.llm.base import LLMProvider from leapflow.llm.message_builder import build_system_message, build_user_message_text from leapflow.world_model._json_utils import extract_json_object @@ -58,49 +59,267 @@ # The teacher is deliberately not asked for a risk level. An intent is a # hypothesis, not an authorisation; the risk ceiling is imposed by the trusted # caller (see ``EvolutionIntent`` / ``MODEL_AUTHORED_RISK_CEILING``). -_GAP_PROMPT_SECTION = """ -Additionally, identify any capability the agent *lacked* -- cases where no -available action could have achieved the goal, as distinct from an available -action being chosen badly. Report only genuine gaps; report none if the agent had -what it needed and merely used it poorly. - -Before reporting a gap, apply these two rules: -- Do NOT restate the task, the goal, or the episode name as a capability. A - capability is a reusable ability such as "chat.reply", never a description of - this particular attempt. -- If the episode failed for a reason that is not a missing capability -- a label - was renamed, an element moved, a transient error, a wrong choice among - available actions -- return an empty list. An invented capability is worse than - a missed one, because it will be built. - -For each gap provide: -- capability: a stable dotted capability name (e.g. "chat.reply"). -- hypothesis: what is missing or broken, in one sentence. -- confidence: float in [0, 1]. -- target_affordance: the environment affordance a new adapter should target, if visible. -- rationale: why the existing capabilities cannot serve this. -- expected_effect: what should observably happen once the capability exists. +#: How many declared capability names to show the teacher. Bounded so a large +#: registry cannot crowd out the trajectory it is supposed to be grading. +_MAX_DECLARED_SHOWN = 60 -Add to the JSON: -{{"capability_gaps": [{{"capability": "...", "hypothesis": "...", \ -"confidence": 0.7, "target_affordance": "...", "rationale": "...", \ -"expected_effect": "..."}}, ...]}} -Use an empty list when there is no genuine gap.""" -#: A capability name is a short dotted path of identifier-like segments. Bounded -#: deliberately: a model asked for a capability sometimes answers with a sentence, and a -#: sentence must never become a requirement. -_CAPABILITY_RE = re.compile(r"^[a-z][a-z0-9_]{1,31}(\.[a-z0-9][a-z0-9_]{0,31}){1,3}$") +def _declared_capability_section() -> str: + """The capability names tools already declare, for the teacher to reuse. + Shown, never enforced. A teacher constrained to this list could no longer report a + genuinely *missing* capability, which is the main thing it is asked for. Shown so + that when the capability does exist under a name the teacher would not have + guessed, it names the existing one -- otherwise the same ability accumulates a + second name, and a capability with two names has one provider each instead of two + competing providers for one name. + + Read live from the registry rather than from a table: a third-party or generated + plugin's declarations must appear too, and a hardcoded list would go stale the + moment the tool set changed. + """ + try: + from leapflow.plugins import get_registry + + registry = get_registry() + names = sorted( + { + capability + for plugin in registry.plugins.values() + for tool in plugin.tools + for capability in (tool.provides_capabilities or ()) + if capability + } + ) + except Exception: # noqa: BLE001 - no catalog degrades the hint, not the grading + logger.debug("teacher: declared capability catalog unavailable", exc_info=True) + return "" + if not names: + return "" + shown = names[:_MAX_DECLARED_SHOWN] + more = f" (and {len(names) - len(shown)} more)" if len(names) > len(shown) else "" + return ( + "\nCapability names already declared by existing tools" + + more + + ". If the ability you\nare reporting is one of these, use that exact name; only invent a new name when\nnone of these is the ability in question:\n" + + ", ".join(shown) + + "\n" + # Naming the list is not the same as saying any of it fits. Measured: asked about + # a chat app failure while shown this catalogue, a real model answered `rebind` + # on 3 of 3 trials -- pointing at a neighbour that shares no environment with the + # failure, on a unit whose candidate set had exactly one entry. A `rebind` whose + # target cannot serve the environment is worse than no recommendation: it leaves + # the failure in place and puts a misleading "Prefer X" in front of every later + # turn. So the list is scoped to what it is for. + + "This list is for *naming*. It does not mean any of these can serve the failing\n" + "capability -- most require an environment that is not present. Only answer\n" + "`rebind` when a capability here is genuinely a provider for the same ability in\n" + "the same environment, and name it in `target`. If you cannot point to one,\n" + "`rebind` is the wrong action.\n" + ) + + +def _offered_providers( + degraded: Sequence[Mapping[str, Any]] = (), +) -> frozenset[str]: + """Every provider named to the teacher as an alternative. + + A target it picked from a list we supplied has to be acceptable, whatever the registry + currently holds. The registry check stays as a second route -- it catches a target the + teacher invented rather than selected. + """ + names: set[str] = set() + for fact in degraded or (): + for row in fact.get("alternatives") or (): + for key in ("tool_name", "plugin_id"): + value = str(row.get(key) or "").strip() + if value: + names.add(value) + return frozenset(names) + + +def _alternatives_line(rows: Sequence[Mapping[str, Any]]) -> str: + """Render the other providers of a capability, and whether each can run here. + + Stated as an absence when there are none, because "no alternative exists" is the + positive evidence for ``acquire`` -- and a silent omission would read as "not + checked", which is exactly the ambiguity that produced a guess. + """ + if not rows: + return "\n no other installed provider offers this capability" + usable = [r for r in rows if r.get("fits_here")] + parts = [ + f"{r.get('tool_name') or r.get('plugin_id')}" + + ("" if r.get("fits_here") else f" (needs {', '.join(r.get('requires') or ()) or 'unmet affordances'})") + for r in rows + ] + verdict = ( + "one of these could take over" + if usable + else "none of these can run in this environment" + ) + return f"\n other providers: {'; '.join(parts)} -- {verdict}" + + +def _degraded_capability_section(degraded: Sequence[Mapping[str, Any]]) -> str: + """Capabilities whose current provider has been failing, for the teacher to judge. + + Facts only, and deliberately without a verdict attached. A consecutive-failure + count cannot distinguish a badly written implementation from an environment that + moved underneath a correct one -- both produce the same streak and they want + opposite actions, rebuild versus rebind. The teacher has the trajectory and + hindsight, so it is the component that can tell them apart; passing it a threshold + decision would replace that judgement with a counter. + + Empty when nothing is degraded, so the prompt gains nothing on a healthy session. + """ + rows = [ + ( + str(item.get("capability") or "").strip(), + str(item.get("plugin_id") or "").strip(), + int(item.get("failure_streak") or 0), + str(item.get("failure_class") or "").strip(), + ) + for item in degraded or () + ] + rows = [row for row in rows if row[0]] + if not rows: + return "" + alternatives = { + str(item.get("capability") or ""): tuple(item.get("alternatives") or ()) + for item in degraded or () + } + prior = { + str(item.get("capability") or ""): ( + str(item.get("prior_action") or ""), + str(item.get("prior_knowledge") or ""), + ) + for item in degraded or () + if item.get("prior_knowledge") + } + lines = "\n".join( + f"- {capability}: current provider {plugin or '(unknown)'} has " + f"{streak} consecutive failure(s) and is still serving" + + (f", failing as {failure_class}" if failure_class else "") + # What was concluded last time. Stated as history rather than as a verdict on + # the verdict: knowledge outliving the failure it describes is evidence the + # previous adaptation did not resolve it, not proof the judgement was wrong. + + ( + f"\n (last time you answered '{prior[capability][0]}' and recorded: " + f"{prior[capability][1]})" + if capability in prior + else "" + ) + # The fact `rebind` and `acquire` are *defined* by. Without it the choice between + # them is a guess, which is what a real model was measured doing. + + _alternatives_line(alternatives.get(capability, ())) + for capability, plugin, streak, failure_class in sorted(rows) + ) + # Named when every degradation shares one environment, because then the right + # answer is usually one rebind rather than one rebuild per capability. + shared = { + str((item.get("environment") or {}).get("fingerprint_id") or "") + for item in degraded or () + } + # Only meaningful for two or more: telling the teacher that one degradation "may be + # one change rather than several" is noise that reads as a hint it must reconcile. + common = ( + "\nAll of these were seen in the same environment, so they may be one change " + "rather than several.\n" + if len(rows) > 1 and len(shared) == 1 and next(iter(shared)) + else "" + ) + return ( + "\nCapabilities whose existing provider has been failing while still in\n" + "service. A capability appearing here already exists, so the question is not\n" + "whether the ability is absent -- it is which of the four actions the evidence\n" + "supports. Prefer the cheapest that fits: absorb costs nothing, rebind reuses\n" + "what is installed, and acquire replaces a working-but-wrong implementation at\n" + "the price of new code.\n" + common + lines + "\n" + ) -def _is_capability_name(value: str) -> bool: - """Whether a teacher-supplied string is shaped like a capability at all. - Requires lowercase dotted structure with 2-4 segments. Rejects prose, bare words, - paths, and anything long enough to be a description rather than a name. +_GAP_PROMPT_SECTION = """ + +Additionally, judge what this episode's evidence says the system should *do* about +the environment it ran in. You are being asked for an action, not for blame: when an +application upgrades, the existing implementation was not written wrongly -- it was +right for the old version -- and yet a new adapter may still be the only way forward. +"Whose fault is it" and "what should be done" are different questions. + +Choose one action per capability, from these four only, cheapest first: +- absorb: the retry or semantic-addressing layer already handles this. A label moved, + an element was renamed, a call timed out. The capability set does not change. + This is the correct answer most of the time. +- rebind: another installed capability already covers the new environment. Name it in + `target`. Prefer this over acquire whenever anything already declared fits. +- acquire: nothing installed covers this, so a new implementation is warranted. This is + the ONLY action that causes code to be written, so use it last. +- escalate: this needs a person -- a missing permission or credential, or a decision the + agent must not make for itself. Put what the human has to do in `target`. + +Every verdict MUST carry `knowledge`: one or two sentences stating what is now true +about the environment, written for the agent that will act next. It is read as ordinary +context, so write a statement about the world ("the send control is now labelled +Dispatch and lives in the toolbar"), never an instruction to the framework ("regenerate +the plugin"). This field is the point of the exercise: three of the four actions change +nothing except what the acting agent knows. + +Two rules that override everything above: +- Do NOT restate the task, the goal, or the episode name as a capability. A capability + is a reusable ability such as "chat.reply", never a description of this attempt. +- Report nothing at all if the episode's failure was simply a wrong choice among + actions that were available and working. An invented verdict is worse than a missed + one, because acquire builds code and rebind redirects traffic. + +For each verdict provide: +- action: one of absorb | rebind | acquire | escalate. +- capability: a stable dotted capability name (e.g. "chat.reply"). +- knowledge: what is now true about the environment. Required. +- rationale: why this action rather than a cheaper one, in one sentence. +- confidence: float in [0, 1]. +- target: for rebind, the capability or tool to use instead; for escalate, what the + human must do; omit otherwise. +- target_affordance: for acquire, the environment affordance a new adapter should + target, if visible. +- expected_effect: for acquire, what should observably happen once it exists. +{declared_section}{degraded_section} +Add to the JSON: +{{"adaptation_verdicts": [{{"action": "rebind", "capability": "...", \ +"knowledge": "...", "rationale": "...", "confidence": 0.7, "target": "...", \ +"target_affordance": "...", "expected_effect": "..."}}, ...]}} +Use an empty list when the episode warrants no adaptation.""" + +def _is_declared_capability(name: str) -> bool: + """Whether some live tool declares this capability or answers to this tool name. + + Reads the registry, not a list, so a generated or third-party plugin counts. Accepts a + tool name as well as a capability name because a teacher naming a concrete provider is + being *more* specific than asked, and rejecting that would push it toward the vaguer + answer. + + An unavailable registry returns ``True``: the guard exists to catch a target that is + demonstrably absent, and failing closed here would silently discard every rebind in + any process that composes no registry. """ - return bool(value) and len(value) <= 96 and bool(_CAPABILITY_RE.match(value)) + candidate = str(name or "").strip() + if not candidate: + return False + try: + from leapflow.plugins import get_registry + + registry = get_registry() + for plugin in registry.plugins.values(): + for tool in plugin.tools: + if tool.name == candidate: + return True + if candidate in (tool.provides_capabilities or ()): + return True + except Exception: # noqa: BLE001 - no registry cannot mean no valid rebind + logger.debug("teacher: cannot verify rebind target", exc_info=True) + return True + return False def _echoes_goal(capability: str, goal: str) -> bool: @@ -131,13 +350,28 @@ class ActionGrade: class TeacherVerdict: """Everything one hindsight evaluation produced. - ``grades`` distil into the experience store as advantage signal; ``intents`` - are capability hypotheses that may drive self-evolution. Both are derived from - a single LLM call, so a verdict costs one ``grading`` budget token. + ``grades`` distil into the experience store as advantage signal; ``verdicts`` say + what the episode's evidence warrants doing about the environment. Both come from a + single LLM call, so a verdict costs one ``grading`` budget token. + + ``intents`` is *derived* from the ``acquire`` verdicts rather than parsed + separately. One source of truth: an ``EvolutionIntent`` can only exist because a + verdict asked for code to be written, so a recommendation to *rebind* can never + silently queue an acquisition. """ grades: tuple[ActionGrade, ...] = () - intents: tuple[EvolutionIntent, ...] = () + verdicts: tuple[AdaptationVerdict, ...] = () + + @property + def intents(self) -> tuple[EvolutionIntent, ...]: + """The acquisition intents, one per ``acquire`` verdict.""" + derived = (verdict.to_intent() for verdict in self.verdicts) + return tuple(intent for intent in derived if intent is not None) + + def by_action(self, action: str) -> tuple[AdaptationVerdict, ...]: + """Verdicts asking for one particular action.""" + return tuple(v for v in self.verdicts if v.action == action) class TrajectoryGrader: @@ -190,6 +424,8 @@ async def grade_and_propose( self, trajectory: List[dict], goal: str = "", + *, + degraded_capabilities: Sequence[Mapping[str, Any]] = (), ) -> "TeacherVerdict": """Grade the trajectory *and* propose capability gaps, in one LLM call. @@ -209,11 +445,17 @@ async def grade_and_propose( return TeacherVerdict((), ()) traj_text = self._format_trajectory(trajectory) - payload = await self._call_teacher_raw(traj_text, goal, propose_gaps=True) + payload = await self._call_teacher_raw( + traj_text, goal, propose_gaps=True, + degraded_capabilities=degraded_capabilities, + ) self._budget.spend("grading") grades = self._persist_grades(trajectory, self._parse_grades(payload)) - return TeacherVerdict(tuple(grades), self._parse_intents(payload, goal)) + return TeacherVerdict( + tuple(grades), + self._parse_verdicts(payload, goal, degraded_capabilities), + ) async def _call_teacher_raw( self, @@ -221,6 +463,7 @@ async def _call_teacher_raw( goal: str, *, propose_gaps: bool = False, + degraded_capabilities: Sequence[Mapping[str, Any]] = (), ) -> dict: """Single LLM call: teacher evaluates with full hindsight. @@ -236,7 +479,10 @@ async def _call_teacher_raw( example_label=self._grade_labels[1] if len(self._grade_labels) > 1 else self._grade_labels[0], ) if propose_gaps: - prompt += _GAP_PROMPT_SECTION.format() + prompt += _GAP_PROMPT_SECTION.format( + declared_section=_declared_capability_section(), + degraded_section=_degraded_capability_section(degraded_capabilities), + ) try: resp = await self._llm.achat( [build_system_message( @@ -281,11 +527,18 @@ def _parse_grades(self, payload: dict) -> List[ActionGrade]: )) return results - def _parse_intents(self, payload: dict, goal: str = "") -> tuple[EvolutionIntent, ...]: - """Parse declared capability gaps into intents, skipping malformed entries. + def _parse_verdicts( + self, + payload: dict, + goal: str = "", + degraded_capabilities: Sequence[Mapping[str, Any]] = (), + ) -> tuple[AdaptationVerdict, ...]: + """Parse declared adaptation verdicts, skipping the ones that cannot be acted on. - A gap without both a ``capability`` and a ``hypothesis`` is discarded: the - capability name must be declared, never inferred from prose. + A verdict without a declared capability, a recognised action, or knowledge is + discarded: the capability name must be declared, never inferred from prose, and a + verdict that teaches the acting agent nothing leaves the teacher with no effect + even when its judgement was right. Two further rejections exist because a live model was measured doing exactly this. Asked to diagnose an episode that failed for a *non-capability* reason, @@ -295,17 +548,27 @@ def _parse_intents(self, payload: dict, goal: str = "") -> tuple[EvolutionIntent faithfully tried to build ``chat.cosmetic.example``. So a capability must *look* like a capability, and must not be a restatement of - the goal. Neither check can catch a plausible-but-wrong capability; that is what + the goal. Neither check can catch a plausible-but-wrong verdict; that is what validation, effect verification and quarantine are for. These catch the degenerate case, which is the one that produces pure noise. """ - raw_gaps = payload.get("capability_gaps", []) if isinstance(payload, dict) else [] - intents: List[EvolutionIntent] = [] - for raw in raw_gaps: + raw_verdicts = ( + payload.get("adaptation_verdicts", []) if isinstance(payload, dict) else [] + ) + # Providers we ourselves named as alternatives. Rejecting a target we offered would + # be incoherent, and it silently was: the guard consulted only the live registry, + # so in any process whose alternatives come from somewhere else -- a replay, a + # study, a registry that has not caught up -- every legitimate rebind was + # discarded. Measured as 3/3 silence on a rebind unit across two unrelated corpus + # designs, which read as "the model will not answer rebind" and was in fact "we + # threw the answer away". + offered = _offered_providers(degraded_capabilities) + verdicts: List[AdaptationVerdict] = [] + for raw in raw_verdicts: if not isinstance(raw, dict): continue capability = str(raw.get("capability") or "").strip() - if not _is_capability_name(capability): + if not is_capability_name(capability): logger.debug( "trajectory_grader: rejected non-capability name %r", capability ) @@ -319,18 +582,37 @@ def _parse_intents(self, payload: dict, goal: str = "") -> tuple[EvolutionIntent confidence = float(raw.get("confidence", 0.0)) except (TypeError, ValueError): confidence = 0.0 + action = str(raw.get("action") or "").strip().lower() + target = str(raw.get("target") or "").strip() + if action == "rebind" and not ( + target in offered or _is_declared_capability(target) + ): + # A rebind that cannot point at something real is not a cheaper answer, + # it is a dead end wearing one: the failure stays and the student is told + # to "Prefer" a provider that does not exist. Measured on a real model at + # 3 of 3 trials, pointing at a neighbour from the naming catalogue. + logger.debug( + "trajectory_grader: rejected rebind to unknown target %r", target + ) + continue try: - intents.append(EvolutionIntent.create( - capability, - str(raw.get("hypothesis") or ""), - confidence=confidence, - target_affordance=str(raw.get("target_affordance") or ""), - rationale=str(raw.get("rationale") or ""), - expected_effect=str(raw.get("expected_effect") or ""), - )) + verdicts.append( + AdaptationVerdict.create( + str(raw.get("action") or ""), + capability, + str(raw.get("knowledge") or ""), + rationale=str(raw.get("rationale") or ""), + confidence=confidence, + target=str(raw.get("target") or ""), + target_affordance=str(raw.get("target_affordance") or ""), + expected_effect=str(raw.get("expected_effect") or ""), + ) + ) except ValueError: - logger.debug("trajectory_grader: discarded malformed capability gap %r", raw) - return tuple(intents) + logger.debug( + "trajectory_grader: discarded unusable adaptation verdict %r", raw + ) + return tuple(verdicts) def _persist_grades( self, diff --git a/src/leapspace/__init__.py b/src/leapspace/__init__.py index ac6cd9d7..265a2d26 100644 --- a/src/leapspace/__init__.py +++ b/src/leapspace/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapSpace — the self-evolving app environment and evaluation harness. The code lives in the ``leapspace.app_space`` submodule (actor, task config, diff --git a/src/leapspace/app_space/__init__.py b/src/leapspace/app_space/__init__.py index 75b5f810..916851bc 100644 --- a/src/leapspace/app_space/__init__.py +++ b/src/leapspace/app_space/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """app_space — the LeapSpace core: actor, task config, lint, and the apps. Kept import-light on purpose: ``leapspace.app_space`` itself must import diff --git a/src/leapspace/app_space/action_lint.py b/src/leapspace/app_space/action_lint.py index 5ef1f7d4..6276e57f 100644 --- a/src/leapspace/app_space/action_lint.py +++ b/src/leapspace/app_space/action_lint.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Static checks for a task's action.py (structure + import safety). Dual use: CLI (``python -m leapspace.app_space.action_lint ``) and harness diff --git a/src/leapspace/app_space/action_utils.py b/src/leapspace/app_space/action_utils.py index 0f9d3716..ba96e6d8 100644 --- a/src/leapspace/app_space/action_utils.py +++ b/src/leapspace/app_space/action_utils.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """action_utils — in-sandbox X11/AT-SPI primitives driven via ``python -m``. One module, three functions, dispatched by function name: diff --git a/src/leapspace/app_space/actor.py b/src/leapspace/app_space/actor.py index 1f4dea51..53e75fd8 100644 --- a/src/leapspace/app_space/actor.py +++ b/src/leapspace/app_space/actor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapAppActor: the OS-signal source of leapspace. Drives the in-sandbox apps through the cua-driver MCP tool surface plus diff --git a/src/leapspace/app_space/apps/__init__.py b/src/leapspace/app_space/apps/__init__.py index e800fe2a..abf0e0b8 100644 --- a/src/leapspace/app_space/apps/__init__.py +++ b/src/leapspace/app_space/apps/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapSpace scenario apps: the host-side app registry. APP_MODULES is the only place an app_id resolves to its module: the diff --git a/src/leapspace/app_space/apps/_base.py b/src/leapspace/app_space/apps/_base.py index a7a0d712..9c6f9b31 100644 --- a/src/leapspace/app_space/apps/_base.py +++ b/src/leapspace/app_space/apps/_base.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """BaseLeapApp — abstract base for all LeapSpace scenario apps. Contract: diff --git a/src/leapspace/app_space/apps/chat.py b/src/leapspace/app_space/apps/chat.py index 2eeb9b8e..48116707 100644 --- a/src/leapspace/app_space/apps/chat.py +++ b/src/leapspace/app_space/apps/chat.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapChat — LeapSpace mock chat app (PyQt6, BaseLeapApp contract). Communication is mocked: outbound messages only append to local state; diff --git a/src/leapspace/app_space/config.py b/src/leapspace/app_space/config.py index ee8a9a37..34c11ddf 100644 --- a/src/leapspace/app_space/config.py +++ b/src/leapspace/app_space/config.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """AppTaskConfig — config.yaml loading and validation (harness-side). config.yaml is pure declaration: wiring and metadata only, no behavior diff --git a/src/leapspace/app_space/e2e.py b/src/leapspace/app_space/e2e.py index e69de29b..b937315b 100644 --- a/src/leapspace/app_space/e2e.py +++ b/src/leapspace/app_space/e2e.py @@ -0,0 +1 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. diff --git a/src/leapspace/app_space/event_view.py b/src/leapspace/app_space/event_view.py index 794a3627..a709c11d 100644 --- a/src/leapspace/app_space/event_view.py +++ b/src/leapspace/app_space/event_view.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """event_view — render a signal evidence DuckDB as a human-readable timeline. Read-only viewer for the eval.duckdb a signal-mode run leaves behind (the diff --git a/src/leapspace/app_space/harness.py b/src/leapspace/app_space/harness.py index b733e907..152844c4 100644 --- a/src/leapspace/app_space/harness.py +++ b/src/leapspace/app_space/harness.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapAppHarness — end-to-end orchestration of one LeapSpace task run. Loads a task config, lints it, boots a disposable sandbox, injects the diff --git a/src/leapspace/app_space/signal.py b/src/leapspace/app_space/signal.py index a12ed52c..c12198d0 100644 --- a/src/leapspace/app_space/signal.py +++ b/src/leapspace/app_space/signal.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapSignal — the in-sandbox leapflow entry for signal-mode runs. One process owns the whole observation stack: EventBus (real normalizer diff --git a/src/leapspace/app_space/tasks/task-001/action.py b/src/leapspace/app_space/tasks/task-001/action.py index 7827b73c..e5f34270 100644 --- a/src/leapspace/app_space/tasks/task-001/action.py +++ b/src/leapspace/app_space/tasks/task-001/action.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """task-001 — Reply to the boss's unread message (LeapChat). Three parts in one file: diff --git a/src/leapspace/app_space/utils.py b/src/leapspace/app_space/utils.py index 2673269a..3e87dc91 100644 --- a/src/leapspace/app_space/utils.py +++ b/src/leapspace/app_space/utils.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shared leapspace helpers: image presets, the state-dir convention, and task action loading.""" diff --git a/temp/plugin_exp/scripts/adaptive_plugin_exp.py b/temp/plugin_exp/scripts/adaptive_plugin_exp.py index b6df7d45..cfa45eb8 100644 --- a/temp/plugin_exp/scripts/adaptive_plugin_exp.py +++ b/temp/plugin_exp/scripts/adaptive_plugin_exp.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# Copyright (c) Alibaba, Inc. and its affiliates. """Deterministic adaptive plugin scenario-matrix experiment. This harness validates the adaptive decision layer above plugin lifecycle diff --git a/temp/plugin_exp/scripts/native_dsh_plugin_exp.py b/temp/plugin_exp/scripts/native_dsh_plugin_exp.py index 73ed1549..eb693c1e 100644 --- a/temp/plugin_exp/scripts/native_dsh_plugin_exp.py +++ b/temp/plugin_exp/scripts/native_dsh_plugin_exp.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# Copyright (c) Alibaba, Inc. and its affiliates. """Run real DeepSeek Harness plugin artifacts through LeapFlow's DSH bridge. The experiment uses source material from a local deepseek-harness checkout. It diff --git a/tests/__init__.py b/tests/__init__.py index 4b3a69b1..1e951fe6 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LeapFlow test package.""" diff --git a/tests/_harness/__init__.py b/tests/_harness/__init__.py index 001124d7..0f625ae2 100644 --- a/tests/_harness/__init__.py +++ b/tests/_harness/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Test harness for the real end-to-end layer. Modules here are infrastructure, not tests: diff --git a/tests/_harness/cassette.py b/tests/_harness/cassette.py index 8569f64e..5f425996 100644 --- a/tests/_harness/cassette.py +++ b/tests/_harness/cassette.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cassette store: request fingerprinting, persistence, and miss diagnostics. A cassette is one recorded OpenAI-compatible HTTP exchange. Recording real diff --git a/tests/_harness/cassette_proxy.py b/tests/_harness/cassette_proxy.py index 47a8ecdb..13ccd2c1 100644 --- a/tests/_harness/cassette_proxy.py +++ b/tests/_harness/cassette_proxy.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Local OpenAI-compatible proxy that records, replays, or forwards LLM traffic. Why a proxy instead of patching the provider: ``OpenAIChat`` builds its diff --git a/tests/_harness/journey.py b/tests/_harness/journey.py index 5589c4c4..d728650f 100644 --- a/tests/_harness/journey.py +++ b/tests/_harness/journey.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Journey runner: one coarse end-to-end test made diagnosable. The real layer is deliberately small — a handful of journeys, each covering many diff --git a/tests/_harness/leapd.py b/tests/_harness/leapd.py index 4ea66052..273c4200 100644 --- a/tests/_harness/leapd.py +++ b/tests/_harness/leapd.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Spawn and drive a real ``leapd`` subprocess for end-to-end journeys. Journeys talk to the daemon over its actual Unix-socket RPC, because that is the diff --git a/tests/conftest.py b/tests/conftest.py index cfdccf37..655c87ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Shared fixtures, factories, and stubs for LeapFlow scenario tests.""" from __future__ import annotations diff --git a/tests/journeys/__init__.py b/tests/journeys/__init__.py index c0e19498..971ac715 100644 --- a/tests/journeys/__init__.py +++ b/tests/journeys/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Real end-to-end journeys: coarse, cross-module, always run.""" diff --git a/tests/journeys/conftest.py b/tests/journeys/conftest.py index 3a0e5724..179c9d09 100644 --- a/tests/journeys/conftest.py +++ b/tests/journeys/conftest.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Fixtures for the real end-to-end journey layer. Every journey runs against a real ``leapd`` subprocess with the LLM boundary diff --git a/tests/journeys/test_r1_conversation.py b/tests/journeys/test_r1_conversation.py index a6b6b29d..5a19899d 100644 --- a/tests/journeys/test_r1_conversation.py +++ b/tests/journeys/test_r1_conversation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """R1 — the conversation main line, end to end through a real daemon. Phases: first turn → streamed chunks → native tool call → tool result fed back → diff --git a/tests/journeys/test_r2_isolation.py b/tests/journeys/test_r2_isolation.py index 2917e7bd..db288d43 100644 --- a/tests/journeys/test_r2_isolation.py +++ b/tests/journeys/test_r2_isolation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """R2 — concurrency and session identity across two workspaces on one daemon. Several TUIs in different workspaces sharing one leapd is a supported way to use diff --git a/tests/journeys/test_r3_control_plane.py b/tests/journeys/test_r3_control_plane.py index 688aafed..ab9d90e3 100644 --- a/tests/journeys/test_r3_control_plane.py +++ b/tests/journeys/test_r3_control_plane.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """R3 — the control plane: slash commands, layered config, secrets, cancellation. `leap config` / `/config` is the only sanctioned way to change durable settings, diff --git a/tests/journeys/test_r4_recovery.py b/tests/journeys/test_r4_recovery.py index 3500e313..efa5c91e 100644 --- a/tests/journeys/test_r4_recovery.py +++ b/tests/journeys/test_r4_recovery.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """R4 — failure and recovery, driven by real provider wire semantics. Every failure here arrives as an actual HTTP response through the real ``openai`` diff --git a/tests/journeys/test_r5_learning.py b/tests/journeys/test_r5_learning.py index 7a5863c1..07273bcf 100644 --- a/tests/journeys/test_r5_learning.py +++ b/tests/journeys/test_r5_learning.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """R5 — the learning loop: teach → record → stop → distill → skill visible. Progressive Trust starts at recording, so the loop only means something if each diff --git a/tests/journeys/test_r6_lifecycle.py b/tests/journeys/test_r6_lifecycle.py index 337d810f..e4906427 100644 --- a/tests/journeys/test_r6_lifecycle.py +++ b/tests/journeys/test_r6_lifecycle.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """R6 — daemon runtime lifecycle: start, report, restart, stop, recover from stale state. Lifecycle is only meaningful across processes: a PID file, a Unix socket and a diff --git a/tests/journeys/test_r7_adaptive_plugin_loop.py b/tests/journeys/test_r7_adaptive_plugin_loop.py index 17dfeea2..d7103436 100644 --- a/tests/journeys/test_r7_adaptive_plugin_loop.py +++ b/tests/journeys/test_r7_adaptive_plugin_loop.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """R7 — adaptive plugin closed loop through a real daemon. Phases: missing capability evidence is observed, a fixture plugin is installed diff --git a/tests/journeys/test_r8_hardware.py b/tests/journeys/test_r8_hardware.py index a62a7c83..c42d6677 100644 --- a/tests/journeys/test_r8_hardware.py +++ b/tests/journeys/test_r8_hardware.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """R8 — physical bench end-to-end through a real daemon. Phases: a simulated device is discovered and described, the sampling loop lands diff --git a/tests/leapspace/test_action_lint.py b/tests/leapspace/test_action_lint.py index 6e9fd19d..99a8652f 100644 --- a/tests/leapspace/test_action_lint.py +++ b/tests/leapspace/test_action_lint.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic unit tests for action_lint (AST only, no Qt, no sandbox). """ diff --git a/tests/leapspace/test_action_utils.py b/tests/leapspace/test_action_utils.py index 70e65446..7210ad4a 100644 --- a/tests/leapspace/test_action_utils.py +++ b/tests/leapspace/test_action_utils.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for action_utils — dispatch contract and lazy-import safety.""" import subprocess diff --git a/tests/leapspace/test_actor.py b/tests/leapspace/test_actor.py index 99c27c61..14e6b285 100644 --- a/tests/leapspace/test_actor.py +++ b/tests/leapspace/test_actor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for LeapAppActor's routing policy and polling helpers.""" import pytest diff --git a/tests/leapspace/test_base.py b/tests/leapspace/test_base.py index 195ae314..c67aec5c 100644 --- a/tests/leapspace/test_base.py +++ b/tests/leapspace/test_base.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic unit tests for BaseLeapApp (offscreen Qt, tmp state dirs).""" import json diff --git a/tests/leapspace/test_config.py b/tests/leapspace/test_config.py index 21c9054e..a5df6895 100644 --- a/tests/leapspace/test_config.py +++ b/tests/leapspace/test_config.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic unit tests for AppTaskConfig (no Qt, no sandbox). """ diff --git a/tests/leapspace/test_event_view.py b/tests/leapspace/test_event_view.py index cee280cde..53ab2868 100644 --- a/tests/leapspace/test_event_view.py +++ b/tests/leapspace/test_event_view.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for event_view — timeline rendering over a temp evidence db.""" import json diff --git a/tests/leapspace/test_harness.py b/tests/leapspace/test_harness.py index d5f076a3..4e7fc0a3 100644 --- a/tests/leapspace/test_harness.py +++ b/tests/leapspace/test_harness.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic unit tests for LeapAppHarness (fake actor, no sandbox). """ diff --git a/tests/leapspace/test_signal.py b/tests/leapspace/test_signal.py index 140b28ad..262317c9 100644 --- a/tests/leapspace/test_signal.py +++ b/tests/leapspace/test_signal.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic unit tests for LeapSignal's record_* sentinel protocol.""" import asyncio diff --git a/tests/leapspace/test_utils.py b/tests/leapspace/test_utils.py index 62cfb5c5..568d400a 100644 --- a/tests/leapspace/test_utils.py +++ b/tests/leapspace/test_utils.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for leapspace.app_space.utils: check() lines and path conventions.""" import asyncio diff --git a/tests/mock_signals/__init__.py b/tests/mock_signals/__init__.py index d5cbf2b3..0e76c84f 100644 --- a/tests/mock_signals/__init__.py +++ b/tests/mock_signals/__init__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Mock signal injection framework for LeapFlow end-to-end testing.""" from tests.mock_signals.generators import ( diff --git a/tests/mock_signals/__main__.py b/tests/mock_signals/__main__.py index c704d8d4..264f6a06 100644 --- a/tests/mock_signals/__main__.py +++ b/tests/mock_signals/__main__.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Mock signal injection for LeapFlow end-to-end testing. Usage: diff --git a/tests/mock_signals/generators.py b/tests/mock_signals/generators.py index 8482ea76..184f8c9e 100644 --- a/tests/mock_signals/generators.py +++ b/tests/mock_signals/generators.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Signal generators for mock injection testing. Each generator class produces events conforming to the LeapFlow observer diff --git a/tests/mock_signals/profiles.py b/tests/mock_signals/profiles.py index e94fb00d..f307c86c 100644 --- a/tests/mock_signals/profiles.py +++ b/tests/mock_signals/profiles.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Predefined signal injection scenarios (profiles). Each profile describes a complete scenario with a mix of generators and their diff --git a/tests/mock_signals/runner.py b/tests/mock_signals/runner.py index a053cfa6..46af96cb 100644 --- a/tests/mock_signals/runner.py +++ b/tests/mock_signals/runner.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Orchestrator: build pipeline, inject signals, collect metrics, report results. The runner constructs a minimal in-memory LeapFlow pipeline (EventBus + MonitorManager) diff --git a/tests/regression/__init__.py b/tests/regression/__init__.py index 40a4adbb..e0533c99 100644 --- a/tests/regression/__init__.py +++ b/tests/regression/__init__.py @@ -1 +1,2 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Regression ledger: one file per incident, always run, never selected away.""" diff --git a/tests/regression/test_impact_selection.py b/tests/regression/test_impact_selection.py index ece241c1..c0d73eaa 100644 --- a/tests/regression/test_impact_selection.py +++ b/tests/regression/test_impact_selection.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Guards for change-scoped test selection. Selection decides which tests get a chance to fail, so a defect here silently diff --git a/tests/regression/test_incident_ledger.py b/tests/regression/test_incident_ledger.py index 03d4abe5..3b990ae4 100644 --- a/tests/regression/test_incident_ledger.py +++ b/tests/regression/test_incident_ledger.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """The incident ledger: one entry per outage that shipped with a green suite. Every entry below is a real regression that reached users. What they had in diff --git a/tests/regression/test_provider_shape_drift.py b/tests/regression/test_provider_shape_drift.py index 6aed81ea..8f5dc3ea 100644 --- a/tests/regression/test_provider_shape_drift.py +++ b/tests/regression/test_provider_shape_drift.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Provider response-shape drift guard. ``tools/sync_fixtures.py`` distils every recorded cassette into the *shapes* the diff --git a/tests/regression/test_suite_budget.py b/tests/regression/test_suite_budget.py index 9879a234..cd93f2fe 100644 --- a/tests/regression/test_suite_budget.py +++ b/tests/regression/test_suite_budget.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Budget guard for the real end-to-end layer. The real layer earns the right to run on *every* push — never skipped by impact diff --git a/tests/regression/test_test_layer_contracts.py b/tests/regression/test_test_layer_contracts.py index 66ab553f..83c85201 100644 --- a/tests/regression/test_test_layer_contracts.py +++ b/tests/regression/test_test_layer_contracts.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Fitness functions for the test suite itself. The mock layer is an asset — 1400-plus cases of branch coverage that no diff --git a/tests/test_active_signal_source.py b/tests/test_active_signal_source.py index 3772f0b5..d9d874d5 100644 --- a/tests/test_active_signal_source.py +++ b/tests/test_active_signal_source.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for ActiveSignalSource protocol, ActiveSourceManager, and FileWatchSignalSource. Verifies lifecycle management, signal flow, failure isolation, backpressure, diff --git a/tests/test_adaptation_verdict.py b/tests/test_adaptation_verdict.py new file mode 100644 index 00000000..abfbf74a --- /dev/null +++ b/tests/test_adaptation_verdict.py @@ -0,0 +1,466 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Phase B: the teacher answers with an action, and every answer teaches something. + +The prompt this replaces asked a binary question -- "is this a capability gap?" -- and +told the teacher to report one only when the implementation was *wrong*. Checked against +the EVO-02 scenario that reading suppresses the case most needing an answer: when the +application steps from v1 to v2 the incumbent was not written wrongly, it was right for +v1, so the honest answer to "is the implementation wrong" is no and nothing happens -- +even when a new adapter is the only way forward. + +So the answer space is now the set of things the system can do, ordered by cost, and +every answer carries what the acting agent should know. Three of the four actions change +nothing else, which is why the knowledge field is mandatory rather than optional. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from leapflow.domain.adaptation_verdict import ( + ACQUIRE, + ADAPTATION_ACTIONS, + AdaptationVerdict, +) +from leapflow.learning.world_model_driver import WorldModelEvolutionDriver +from leapflow.world_model.trajectory_grader import TeacherVerdict, TrajectoryGrader + + +def _verdict(action: str, capability: str = "chat.reply", **kw: Any) -> AdaptationVerdict: + return AdaptationVerdict.create( + action, capability, kw.pop("knowledge", "the app is now v3"), **kw + ) + + +# ── the action space is closed, and each answer must be actionable ───────────── + + +def test_the_four_actions_are_the_whole_space(): + """An action outside the set has no consumer, so accepting one would be silent.""" + assert ADAPTATION_ACTIONS == {"absorb", "rebind", "acquire", "escalate"} + with pytest.raises(ValueError, match="action must be one of"): + AdaptationVerdict.create("rebuild", "chat.reply", "knowledge") + + +def test_knowledge_is_mandatory_on_every_verdict(): + """A verdict that teaches nothing leaves the teacher with no effect when it is right. + + Three of the four actions change nothing except what the acting agent knows, so a + verdict without knowledge is not a cheap answer -- it is no answer. + """ + for action in sorted(ADAPTATION_ACTIONS): + with pytest.raises(ValueError, match="knowledge is required"): + AdaptationVerdict.create(action, "chat.reply", " ") + + +def test_a_capability_name_must_look_like_one(): + """Measured: a live model returned the episode's own name on 3 of 3 trials. + + The name was well-formed prose, so nothing downstream would have caught it and the + governed pipeline would have faithfully tried to build it. + """ + with pytest.raises(ValueError, match="short dotted name"): + AdaptationVerdict.create("acquire", "reply to the message in the thread", "k") + assert AdaptationVerdict.create("acquire", "chat.reply", "k").capability == "chat.reply" + + +# ── only acquire writes code ─────────────────────────────────────────────────── + + +def test_only_acquire_derives_an_acquisition_intent(): + """A recommendation to rebind must never become a request to write code.""" + assert _verdict(ACQUIRE).to_intent() is not None + for action in ("absorb", "rebind", "escalate"): + assert _verdict(action).to_intent() is None, action + assert _verdict(action).writes_code is False + assert _verdict(ACQUIRE).writes_code is True + + +def test_intents_are_derived_from_verdicts_not_carried_beside_them(): + """One source of truth, so the two can never disagree. + + Keeping them independent is how "I recommend doing X" and "I want a new capability" + get mixed into one object, and then a rebind silently queues an acquisition. + """ + teacher_verdict = TeacherVerdict( + grades=(), + verdicts=( + _verdict("absorb", "chat.react"), + _verdict("rebind", "chat.reply", target="chat_reply_v3"), + _verdict(ACQUIRE, "mail.send"), + ), + ) + + assert len(teacher_verdict.verdicts) == 3 + assert [i.capability for i in teacher_verdict.intents] == ["mail.send"] + assert teacher_verdict.by_action("rebind")[0].target == "chat_reply_v3" + + +def test_a_model_cannot_widen_the_ceiling_and_the_request_stays_auditable(): + """One clamp, one place -- and the original request survives to the approver. + + Clamping inside ``to_intent`` as well looked safer and silently destroyed the audit + trail: the downstream clamp records the request only when it differs from what was + granted, so pre-clamping made the two equal and an approver could no longer see that + the model had asked for more than it got. + """ + from leapflow.learning.capability_gap_detector import CapabilityGapDetector + + wide = AdaptationVerdict.create( + "acquire", "shell.run", "nothing can run shell here", max_risk_level="external" + ) + intent = wide.to_intent() + assert intent.max_risk_level == "external", "the request travels unclamped" + + proposal = CapabilityGapDetector().proposal_from_evolution_intent(intent) + assert proposal.risk_level == "read_only", "the clamp still holds" + metadata = dict(proposal.evidence[0].metadata) + assert metadata["requested_max_risk_level"] == "external", "the ask is on the record" + + +# ── the prompt asks for an action, never for blame ───────────────────────────── + + +def test_the_prompt_asks_what_to_do_rather_than_whose_fault_it_is(): + """The binary reading suppressed the environment-upgrade case entirely.""" + from leapflow.world_model.trajectory_grader import _GAP_PROMPT_SECTION + + section = _GAP_PROMPT_SECTION + for action in sorted(ADAPTATION_ACTIONS): + assert f"- {action}:" in section, action + assert "adaptation_verdicts" in section + assert "MUST carry `knowledge`" in section + # The distinction the old prompt collapsed. + assert "not for blame" in section + assert "different questions" in section + # acquire must be named as the expensive one, so it is not the default answer. + assert "ONLY action that causes code to be written" in section + + +def test_the_prompt_still_refuses_invention(): + """The two guards that caught a measured 3/3 false-positive rate must survive.""" + from leapflow.world_model.trajectory_grader import _GAP_PROMPT_SECTION + + assert "Do NOT restate the task" in _GAP_PROMPT_SECTION + assert "An invented verdict is worse than a missed" in _GAP_PROMPT_SECTION + # And it must say *why* invention is costly, in terms of what happens next. + assert "acquire builds code and rebind redirects traffic" in _GAP_PROMPT_SECTION + + +def test_the_parser_keeps_both_rejections_and_requires_knowledge(): + """Parsing is where a malformed answer must die, not downstream.""" + grader = TrajectoryGrader.__new__(TrajectoryGrader) + payload = { + "adaptation_verdicts": [ + {"action": "absorb", "capability": "chat.reply", "knowledge": "v3 now"}, + {"action": "absorb", "capability": "reply to this thread", "knowledge": "k"}, + {"action": "absorb", "capability": "chat.reply"}, # no knowledge + {"action": "rebuild", "capability": "chat.reply", "knowledge": "k"}, + "not a dict", + ] + } + verdicts = grader._parse_verdicts(payload, goal="reply in the thread") + + assert len(verdicts) == 1 + assert verdicts[0].capability == "chat.reply" + + +def test_a_goal_restatement_is_still_rejected(): + grader = TrajectoryGrader.__new__(TrajectoryGrader) + payload = { + "adaptation_verdicts": [ + {"action": "acquire", "capability": "chat.cosmetic.example", + "knowledge": "k"}, + ] + } + assert grader._parse_verdicts(payload, goal="chat cosmetic example") == () + + +# ── the driver dispatches by action ──────────────────────────────────────────── + + +class _Teacher: + def __init__(self, verdict: TeacherVerdict) -> None: + self._verdict = verdict + + async def grade_and_propose(self, trajectory, goal="", **kwargs): + return self._verdict + + +class _Intake: + def observe_result(self, result, **kwargs): + return {"observation_id": "o1"} + + def requirements(self, *, min_count: int = 1, limit: int = 50): + return () + + +def _drive(verdicts, sink=None): + queued: list[Any] = [] + driver = WorldModelEvolutionDriver( + teacher=_Teacher(TeacherVerdict(grades=(), verdicts=tuple(verdicts))), + intake=_Intake(), + proposal_sink=sink or (lambda p: queued.append(p) or p.proposal_id), + ) + return asyncio.run(driver.drive([{"action": "a"}], "reply in the thread")), queued + + +def test_only_the_acquire_verdict_reaches_the_proposal_queue(): + result, queued = _drive( + [ + _verdict("absorb", "chat.react"), + _verdict("rebind", "chat.reply", target="chat_reply_v3"), + _verdict(ACQUIRE, "mail.send"), + _verdict("escalate", "drive.upload", target="grant drive.file"), + ] + ) + + assert result.to_dict()["by_action"] == { + "absorb": 1, "rebind": 1, "acquire": 1, "escalate": 1 + } + assert len(queued) == 1 + assert dict(queued[0].evidence[0].metadata)["capability"] == "mail.send" + + +def test_the_cheap_verdicts_survive_on_the_result(): + """Dropping them for not writing code would discard the common correct answer.""" + result, _ = _drive( + [ + _verdict("absorb", "chat.react"), + _verdict("rebind", "chat.reply", target="chat_reply_v3"), + _verdict("escalate", "drive.upload"), + ] + ) + + cheap = tuple(v for v in result.verdicts if not v.writes_code) + assert {v.action for v in cheap} == {"absorb", "rebind", "escalate"} + assert all(v.knowledge for v in cheap) + + +def test_a_session_that_only_absorbed_is_not_reported_as_idle(): + """The cheapest answer must be visible, or adapting well looks like doing nothing.""" + result, queued = _drive([_verdict("absorb", "chat.react")]) + + assert queued == [], "absorb writes no code" + assert result.to_dict()["by_action"]["absorb"] == 1 + assert len(result.verdicts) == 1 + assert result.proposed == 0, "absorb is not a proposal" + + +def test_a_teacher_with_nothing_to_say_stays_empty(): + result, queued = _drive([]) + assert result.verdicts == () and queued == [] + assert result.to_dict()["by_action"] == { + "absorb": 0, "rebind": 0, "acquire": 0, "escalate": 0 + } + + +# ── review findings: the derivation must be pure and the rule single ─────────── + + +def test_reading_intents_twice_yields_the_same_intents(): + """An immutable object whose derived value changes on every read is a landmine. + + ``EvolutionIntent.create`` mints its own id and timestamp, so deriving through it + returned a *different* intent on each access -- and ``intent_id`` is the evidence + identity downstream. No single-read test could notice; only comparing two reads can. + """ + verdict = _verdict(ACQUIRE, "mail.send") + teacher_verdict = TeacherVerdict(grades=(), verdicts=(verdict,)) + + first, second = teacher_verdict.intents, teacher_verdict.intents + assert first == second, "derivation must be a pure function of the verdict" + assert first[0].intent_id == second[0].intent_id + assert first[0].created_at == second[0].created_at + + +def test_an_intent_is_traceable_back_to_the_verdict_that_asked_for_it(): + """Identity from the verdict is what makes the derivation pure, and it audits.""" + verdict = _verdict(ACQUIRE, "mail.send") + intent = verdict.to_intent() + assert intent.intent_id.endswith(verdict.verdict_id.removeprefix("adv-")) + + +def test_one_capability_name_rule_governs_both_gates(): + """Two nearly-identical regexes diverged and silently dropped a valid name. + + The parser's gate accepted ``chat.2fa`` and the verdict constructor rejected it, so + a legitimately named capability died with only a debug log between them. + """ + from leapflow.domain.evolution_intent import is_capability_name + from leapflow.world_model.trajectory_grader import TrajectoryGrader + + for name in ("chat.reply", "chat.2fa", "a.b", "x" * 40 + ".y", "reply to the thread"): + gate = is_capability_name(name) + try: + AdaptationVerdict.create("absorb", name, "k") + constructor = True + except ValueError: + constructor = False + assert gate == constructor, f"{name!r}: gate={gate} constructor={constructor}" + + grader = TrajectoryGrader.__new__(TrajectoryGrader) + parsed = grader._parse_verdicts( + {"adaptation_verdicts": [ + {"action": "absorb", "capability": "chat.2fa", "knowledge": "a 2fa prompt appears"} + ]} + ) + assert len(parsed) == 1, "the name the two rules disagreed about must survive" + + +# ── a rebind must be able to point at something real ────────────────────────── + + +def test_a_rebind_to_a_nonexistent_target_is_rejected(): + """Measured on a real model at 3 of 3 trials, and scored as a correct answer. + + Asked about a chat-app failure while shown the naming catalogue of LeapFlow's own + tools, the teacher answered ``rebind`` every time -- on a unit whose candidate set had + exactly one entry, so there was nothing to rebind to. The report read 1.0 accuracy + because only capability naming was scored. A rebind that cannot point at a real + provider is not the cheap answer: the failure stays in place and the student is told + to "Prefer" something that does not exist. + """ + grader = TrajectoryGrader.__new__(TrajectoryGrader) + payload = { + "adaptation_verdicts": [ + { + "action": "rebind", + "capability": "chat.reply", + "knowledge": "the app is now v3", + "target": "no_such_capability.anywhere", + } + ] + } + assert grader._parse_verdicts(payload, goal="reply in the thread") == () + + +def test_a_rebind_to_a_declared_capability_survives(): + """The guard must not reject the case rebind exists for.""" + from leapflow.plugins import get_registry + from leapflow.world_model.trajectory_grader import _is_declared_capability + + declared = sorted( + capability + for plugin in get_registry().plugins.values() + for tool in plugin.tools + for capability in (tool.provides_capabilities or ()) + if capability + ) + assert declared, "the registry must expose declarations for this to mean anything" + assert _is_declared_capability(declared[0]) + + grader = TrajectoryGrader.__new__(TrajectoryGrader) + parsed = grader._parse_verdicts( + { + "adaptation_verdicts": [ + { + "action": "rebind", + "capability": "chat.reply", + "knowledge": "the app is now v3", + "target": declared[0], + } + ] + }, + goal="reply in the thread", + ) + assert len(parsed) == 1 and parsed[0].target == declared[0] + + +def test_a_tool_name_is_an_acceptable_rebind_target(): + """Naming a concrete provider is more specific than asked, not less.""" + from leapflow.plugins import get_registry + from leapflow.world_model.trajectory_grader import _is_declared_capability + + names = [t.name for p in get_registry().plugins.values() for t in p.tools] + assert names + assert _is_declared_capability(names[0]) + + +def test_only_rebind_is_gated_on_its_target(): + """The other three actions do not promise a provider, so they must pass through.""" + grader = TrajectoryGrader.__new__(TrajectoryGrader) + for action in ("absorb", "acquire", "escalate"): + parsed = grader._parse_verdicts( + { + "adaptation_verdicts": [ + { + "action": action, + "capability": "chat.reply", + "knowledge": "something changed", + "target": "no_such_capability.anywhere", + } + ] + }, + goal="reply in the thread", + ) + assert len(parsed) == 1, action + + +def test_the_catalogue_says_what_it_is_for(): + """Listing names invited reading them as "these all fit here".""" + from leapflow.world_model.trajectory_grader import _declared_capability_section + + section = _declared_capability_section() + assert "for *naming*" in section + assert "an environment that is not present" in section + assert "`rebind` is the wrong action" in section + + +def test_a_rebind_to_a_provider_we_offered_is_accepted(): + """Rejecting a target we ourselves named is incoherent, and it silently was. + + The guard consulted only the live registry, so in any process whose alternatives come + from somewhere else -- a replay, a study, a registry that has not caught up -- every + legitimate rebind was discarded. It measured as 3/3 silence on a rebind unit across two + unrelated corpus designs, which read as "the model will not answer rebind" and was in + fact "we threw the answer away". + """ + grader = TrajectoryGrader.__new__(TrajectoryGrader) + facts = ( + { + "capability": "chat.reply", + "plugin_id": "chat_reply_v1", + "failure_streak": 3, + "alternatives": ( + {"plugin_id": "tb", "tool_name": "chat_reply_toolbar", + "fits_here": True, "requires": ()}, + ), + }, + ) + payload = { + "adaptation_verdicts": [ + {"action": "rebind", "capability": "chat.reply", + "knowledge": "the toolbar path still delivers", + "target": "chat_reply_toolbar"} + ] + } + + parsed = grader._parse_verdicts(payload, "reply in the thread", facts) + assert len(parsed) == 1 and parsed[0].target == "chat_reply_toolbar" + + # The registry route remains, and catches a target that was invented rather than + # selected from what it was shown. + invented = { + "adaptation_verdicts": [ + {"action": "rebind", "capability": "chat.reply", "knowledge": "k", + "target": "no_such_provider_anywhere"} + ] + } + assert grader._parse_verdicts(invented, "reply in the thread", facts) == () + + # And with no alternatives offered, only the registry can vouch for a target. + assert grader._parse_verdicts(payload, "reply in the thread", ()) == () + + +def test_the_plugin_id_of_an_offered_alternative_also_counts(): + """The teacher may name either identity; both were shown to it.""" + from leapflow.world_model.trajectory_grader import _offered_providers + + offered = _offered_providers( + ({"alternatives": ({"plugin_id": "tb", "tool_name": "chat_reply_toolbar"},)},) + ) + assert offered == {"tb", "chat_reply_toolbar"} diff --git a/tests/test_adaptive_depth.py b/tests/test_adaptive_depth.py index bc13b5e2..74323a1b 100644 --- a/tests/test_adaptive_depth.py +++ b/tests/test_adaptive_depth.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unit tests for the adaptive-depth core (mechanisms 1+2+3, S0 / W1). Covers: diff --git a/tests/test_adaptive_plugin_loop.py b/tests/test_adaptive_plugin_loop.py index 85741163..bf34e4ea 100644 --- a/tests/test_adaptive_plugin_loop.py +++ b/tests/test_adaptive_plugin_loop.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for adaptive plugin closed-loop orchestration.""" from __future__ import annotations diff --git a/tests/test_agent_execution.py b/tests/test_agent_execution.py index 06ab3fec..3b32d1ec 100644 --- a/tests/test_agent_execution.py +++ b/tests/test_agent_execution.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scenario-based integration tests for the agent execution pipeline.""" from __future__ import annotations diff --git a/tests/test_app_connector.py b/tests/test_app_connector.py index 72e2965d..e56e6600 100644 --- a/tests/test_app_connector.py +++ b/tests/test_app_connector.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations import pytest diff --git a/tests/test_approval_layer.py b/tests/test_approval_layer.py index f0a60c11..7dc99c6f 100644 --- a/tests/test_approval_layer.py +++ b/tests/test_approval_layer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations import builtins diff --git a/tests/test_architecture_contracts.py b/tests/test_architecture_contracts.py index b1a68a95..fef8cbe1 100644 --- a/tests/test_architecture_contracts.py +++ b/tests/test_architecture_contracts.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Executable guards for the architecture contracts in AGENTS.md. These contracts are the ones a code review is worst at catching, because a @@ -395,6 +396,8 @@ def test_frozen_domain_type_rejects_mutation_at_runtime() -> None: ("leapflow.engine.recovery_coordinator", "RecoveryStrategy"), ("leapflow.monitor.types", "MonitorProducer"), ("leapflow.dashboard.service", "DashboardDataProvider"), + ("leapflow.plugins.selection_policy", "SelectionPolicy"), + ("leapflow.plugins.selection_policy", "SelectionPolicyPlugin"), ] @@ -447,6 +450,12 @@ def test_extension_points_are_runtime_checkable_protocols( "leapflow.plugins.tool_plugins", "leapflow.plugins.marketplace", "leapflow.plugins.sandbox", + "leapflow.plugins.selection_policy", + "leapflow.plugins.selection_policy_registry", + "leapflow.plugins._builtin_policies", + "leapflow.domain.adaptation_verdict", + "leapflow.storage.distilled_knowledge_store", + "leapflow.learning.degradation_sink", ] @@ -471,20 +480,33 @@ def test_engine_self_attributes_all_exist() -> None: Names assigned anywhere in the module count as defined, including on frames and per-session clones; this catches the typo case, not lifecycle ordering. + + Public names are checked too. The guard originally matched only ``self._x`` and + a read of ``self.settings`` -- where the engine actually stores ``self._settings`` + -- slipped straight through it, taking out the adaptive capability loop while all + 3602 mock-layer tests stayed green; only a journey caught it. Dropping the leading + underscore is the *more* likely typo, since it is the natural thing to type. + + Scoped to ``AgentEngine``'s own source rather than the whole module, because + ``engine.py`` also defines ``TaskContract`` and ``StreamEvent``, whose public + ``self.x`` reads would otherwise be attributed to the engine and reported as + undefined. """ + import inspect import re from pathlib import Path import leapflow.engine.engine as engine_module - source = Path(engine_module.__file__).read_text(encoding="utf-8") - read = set(re.findall(r"self\.(_[a-z][a-z0-9_]*)", source)) - assigned = set(re.findall(r"self\.(_[a-z][a-z0-9_]*)\s*(?::[^=\n]+)?=", source)) + attribute = r"self\.(_?[a-z][a-z0-9_]*)" + source = inspect.getsource(engine_module.AgentEngine) + read = set(re.findall(attribute, source)) + assigned = set(re.findall(attribute + r"\s*(?::[^=\n]+)?=", source)) # Attributes may also be set from outside (session_factory clones engines). for module in ("leapflow.engine.session_factory", "leapflow.engine.agent_loop"): mod = importlib.import_module(module) assigned |= set( - re.findall(r"engine\.(_[a-z][a-z0-9_]*)\s*=", Path(mod.__file__).read_text(encoding="utf-8")) + re.findall(r"engine\.(_?[a-z][a-z0-9_]*)\s*=", Path(mod.__file__).read_text(encoding="utf-8")) ) on_class = {name for name in read if hasattr(engine_module.AgentEngine, name)} @@ -689,3 +711,35 @@ def test_only_watched_rpcs_get_an_approval_route() -> None: # Writes are deliberately absent: they run through the tool handler, which builds its # own descriptor and is reached from a turn that already owns a route. assert "hardware.write_request" not in _APPROVAL_ROUTED_METHODS + + +def test_a_selection_policy_is_a_core_extension_point_not_a_tool_plugin() -> None: + """No trust, no sandbox, no approval in the policy layer. + + Progressive Trust is earned by *executing tools*; a selection policy executes + none, so a trust level for it would be a meaningless number that the plugin + roster would nonetheless render, and a frozen-on-defect rule would have nothing + to freeze. It also runs inside the turn and reads host services, so it cannot be + sandboxed. The correct template is ``LLMProviderPlugin``, whose registry has the + same three absences -- this test is that reasoning made executable. + """ + import inspect + + from leapflow.plugins import _builtin_policies, selection_policy, selection_policy_registry + + forbidden = ("PluginTrustLedger", "TrustLevel", "requires_sandbox", + "ApprovalOrchestrator", "ActionDescriptor", "RiskLevel") + for module in (selection_policy, selection_policy_registry, _builtin_policies): + source = inspect.getsource(module) + for name in forbidden: + assert name not in source, ( + f"{module.__name__} references {name}: a selection policy is a core " + "extension point, not a governed tool plugin" + ) + + +def test_the_policy_entry_point_group_is_a_published_contract() -> None: + """Third parties pin this string in their packaging; renaming it unregisters them.""" + from leapflow.plugins.selection_policy_registry import ENTRY_POINT_GROUP + + assert ENTRY_POINT_GROUP == "leapflow.selection_policies" diff --git a/tests/test_board_session_binding.py b/tests/test_board_session_binding.py index a659ae28..8fd05394 100644 --- a/tests/test_board_session_binding.py +++ b/tests/test_board_session_binding.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Regression tests: LeapBoard must observe the session that opened it. Root cause of "board opens, status bar shows watch, page stays empty": diff --git a/tests/test_budget_calibration.py b/tests/test_budget_calibration.py index 206861c0..2eca1f5e 100644 --- a/tests/test_budget_calibration.py +++ b/tests/test_budget_calibration.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Contracts for budget-estimator self-calibration. The character heuristic (CJK 1:1, Latin 4:1) cannot match a real tokenizer, and diff --git a/tests/test_build_info.py b/tests/test_build_info.py index 70f70318..f5b4acf1 100644 --- a/tests/test_build_info.py +++ b/tests/test_build_info.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic tests for leapflow.utils.build_info (long-lived-process staleness). All git subprocess calls are monkeypatched at the module's ``_fingerprint`` diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index cff26da5..8d617aab 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations from leapflow.cache.manager import CacheManager, CacheScope diff --git a/tests/test_capability_adaptation_producer.py b/tests/test_capability_adaptation_producer.py index 4a0b2a03..b91cbe66 100644 --- a/tests/test_capability_adaptation_producer.py +++ b/tests/test_capability_adaptation_producer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for capability adaptation monitor producer.""" from __future__ import annotations diff --git a/tests/test_capability_gap_detector.py b/tests/test_capability_gap_detector.py index 45182c04..39f88a0e 100644 --- a/tests/test_capability_gap_detector.py +++ b/tests/test_capability_gap_detector.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for capability gap detection and plugin proposals.""" from __future__ import annotations diff --git a/tests/test_capability_observation.py b/tests/test_capability_observation.py index aeba0722..380de7b9 100644 --- a/tests/test_capability_observation.py +++ b/tests/test_capability_observation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for structured capability observations.""" from __future__ import annotations diff --git a/tests/test_capability_observation_store.py b/tests/test_capability_observation_store.py index b1a60ff1..57ebbfe6 100644 --- a/tests/test_capability_observation_store.py +++ b/tests/test_capability_observation_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for durable capability observation storage and service.""" from __future__ import annotations diff --git a/tests/test_capability_plan.py b/tests/test_capability_plan.py index 6f0ba513..8c71b08c 100644 --- a/tests/test_capability_plan.py +++ b/tests/test_capability_plan.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for declarative capability orchestration plans.""" from __future__ import annotations diff --git a/tests/test_capability_plan_store.py b/tests/test_capability_plan_store.py index fc844196..410eab7b 100644 --- a/tests/test_capability_plan_store.py +++ b/tests/test_capability_plan_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for adaptive capability decision history storage.""" from __future__ import annotations diff --git a/tests/test_capability_proposal_policy.py b/tests/test_capability_proposal_policy.py index faf611ee..e40367c4 100644 --- a/tests/test_capability_proposal_policy.py +++ b/tests/test_capability_proposal_policy.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for adaptive proposal queue and policy decisions.""" from __future__ import annotations diff --git a/tests/test_capability_replacement_trigger.py b/tests/test_capability_replacement_trigger.py new file mode 100644 index 00000000..d8c14ec7 --- /dev/null +++ b/tests/test_capability_replacement_trigger.py @@ -0,0 +1,835 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""T1-T3: the trigger for replacing an existing capability's implementation. + +Before this, self-evolution only ever fired on a *missing* capability. An existing +provider that kept failing was handled by quarantine -- which disables it, creating a +gap, which then triggers generation. That ordering has three consequences the EVO-02 +episode measured: an availability hole between disable and install, never any two +providers admissible at once, and the question "is the replacement actually better?" +never being asked, because the incumbent is gone by the time the rival arrives. + +These tests cover the three pieces that make the other ordering possible: + +* **T1** governance reports a failure that left the plugin *in service* -- the state + between healthy and quarantined, which had no expression at all. +* **T2** the teacher is shown those facts and adjudicates. A failure count cannot tell + a wrong implementation from a moved environment; both produce the same streak and + want opposite actions. +* **T3** an admitted intent becomes a queued proposal, with an identity that lets a + rival coexist with the incumbent it competes against. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from leapflow.domain.evolution_intent import EvolutionIntent +from leapflow.learning.capability_gap_detector import CapabilityGapDetector +from leapflow.learning.capability_observation import ( + CAPABILITY_DEGRADED, + EVIDENCE_SURVIVING_RESOLUTION, + CapabilityEvidenceClassifier, + CapabilityObservationService, +) +from leapflow.learning.world_model_driver import ( + CapabilityGapTeacher, + WorldModelEvolutionDriver, +) +from leapflow.plugins.lifecycle_governor import LifecycleGovernor +from leapflow.storage.capability_observation_store import JsonCapabilityObservationStore + + +# ── T1: governance reports a still-serving failure ───────────────────────────── + + +class _Queue: + def __init__(self) -> None: + self.updates: list[dict[str, Any]] = [] + + def update(self, proposal_id: str, **fields: Any) -> None: + self.updates.append({"proposal_id": proposal_id, **fields}) + + +class _Outcomes: + def __init__(self, streak: int = 0) -> None: + self.streak = streak + self.added: list[dict[str, Any]] = [] + + def add_outcome(self, **kwargs: Any) -> None: + self.added.append(kwargs) + + def failure_streak(self, plugin_id: str) -> int: + return self.streak + + +class _Actor: + def __init__(self) -> None: + self.disabled: list[str] = [] + + async def disable(self, *, plugin_id: str) -> dict[str, Any]: + self.disabled.append(plugin_id) + return {"ok": True} + + +def _governor(streak: int, sink: Any = None, actor: Any = None) -> LifecycleGovernor: + return LifecycleGovernor( + proposal_queue=_Queue(), + outcome_store=_Outcomes(streak), + lifecycle_actor=actor, + degradation_sink=sink, + ) + + +async def _record(governor: LifecycleGovernor, ok: bool, **kw: Any) -> Any: + return await governor.record_outcome( + proposal_id="p1", plugin_id="chat_reply_v1", tool_name="chat_reply_v1", ok=ok, **kw + ) + + +@pytest.mark.asyncio +async def test_a_failure_that_leaves_the_plugin_serving_is_reported(): + """The state that had no expression: failing, not disabled, still answering calls.""" + seen: list[dict[str, Any]] = [] + governor = _governor(streak=2, sink=lambda **kw: seen.append(kw)) + + result = await _record(governor, ok=False) + + assert result.action != "quarantine", "two failures must not disable" + assert seen == [{"plugin_id": "chat_reply_v1", "failure_streak": 2, + "trust_level": "DRAFT", "failure_class": ""}] + + +@pytest.mark.asyncio +async def test_a_quarantined_plugin_is_not_reported_as_degraded(): + """It is no longer serving, so there is nothing to build a rival *alongside*.""" + seen: list[dict[str, Any]] = [] + actor = _Actor() + governor = _governor(streak=3, sink=lambda **kw: seen.append(kw), actor=actor) + + result = await _record(governor, ok=False) + + assert result.action == "quarantine" + assert actor.disabled == ["chat_reply_v1"] + assert seen == [], "a disabled plugin is a gap, not a degradation" + + +@pytest.mark.asyncio +async def test_a_success_reports_a_zero_streak_so_degradation_can_be_retired(): + """A health signal that only fires one way has no way back. + + Reporting only failures left a degradation record open forever: ``unresolved()`` + would grow monotonically and the teacher would keep being told a capability is + failing long after it recovered, driving it to propose rivals for a healthy + provider. A zero streak after a success is the retirement signal, and it is + declarative -- the sink never has to infer recovery from an absence of reports. + """ + seen: list[dict[str, Any]] = [] + await _record(_governor(streak=0, sink=lambda **kw: seen.append(kw)), ok=True) + assert seen == [{"plugin_id": "chat_reply_v1", "failure_streak": 0, + "trust_level": "DRAFT", "failure_class": ""}] + + +@pytest.mark.asyncio +async def test_a_failing_sink_never_breaks_governance(): + """Reporting is advisory; governance drives trust and quarantine.""" + + def explode(**_: Any) -> None: + raise RuntimeError("sink down") + + result = await _record(_governor(streak=1, sink=explode), ok=False) + assert result.action # governance still produced a decision + + +@pytest.mark.asyncio +async def test_governance_works_with_no_sink_installed(): + result = await _record(_governor(streak=1), ok=False) + assert result.action + + +# ── the retirement hazard: degradation must survive a met resolution ─────────── + + +def test_degradation_evidence_is_not_retired_by_finding_the_incumbent(tmp_path): + """The provider that exists *is* the thing being reported. + + ``resolve_capability`` retires evidence whose gap is closed. For ``unknown_tool`` + a provider existing closes it. For degradation it proves nothing -- and retiring it + here would erase the record at the first resolution after it was written, which is + the very next turn. + """ + store = JsonCapabilityObservationStore(tmp_path / "obs.json") + service = CapabilityObservationService( + store, + classifier=CapabilityEvidenceClassifier.from_kinds( + ["unknown_tool", CAPABILITY_DEGRADED] + ), + ) + + service.observe_result( + {"error_type": CAPABILITY_DEGRADED, "capability": "chat.reply", + "plugin_id": "chat_reply_v1", "failure_streak": 2} + ) + service.observe_result( + {"error_type": "unknown_tool", "original_tool_name": "send_chat"} + ) + assert len(store.unresolved()) == 2 + + # A resolution that found a provider for both names. + service.resolve_capability("chat.reply", reason="incumbent serves it") + service.resolve_capability("send_chat", reason="provider found") + + surviving = [ + str((r.get("result") or {}).get("error_type") or "") for r in store.unresolved() + ] + assert surviving == [CAPABILITY_DEGRADED], surviving + assert CAPABILITY_DEGRADED in EVIDENCE_SURVIVING_RESOLUTION + + +# ── T2: the teacher adjudicates, and is given facts not a verdict ────────────── + + +def test_the_degradation_section_states_facts_without_deciding(): + """A streak cannot tell a wrong implementation from a moved environment. + + Both produce consecutive failures and want opposite actions -- rebuild versus + rebind -- so the prompt must hand the teacher the observation and ask it to judge, + not hand it a conclusion. + + This section originally asked the pre-Phase-B binary ("an implementation that is + wrong, or an environment that changed... report a gap only for the former"), which + both contradicted the four-action prompt it sits inside and suppressed the + environment-upgrade case: on an upgrade the implementation was not written wrongly, + so the honest answer to "is it wrong" is no and nothing would happen. + """ + from leapflow.world_model.trajectory_grader import _degraded_capability_section + + section = _degraded_capability_section( + [{"capability": "chat.reply", "plugin_id": "chat_reply_v1", "failure_streak": 2}] + ) + + assert "chat.reply" in section and "chat_reply_v1" in section + assert "2 consecutive failure" in section + # It must say the capability exists, so the question is which action -- not whether + # the ability is absent. + assert "already exists" in section + # And it must point at the action space rather than pre-empting the judgement. + assert "which of the four actions the evidence" in section + assert "Report a gap only" not in section + + +def test_a_healthy_session_adds_nothing_to_the_prompt(): + from leapflow.world_model.trajectory_grader import _degraded_capability_section + + assert _degraded_capability_section(()) == "" + assert _degraded_capability_section([{"capability": ""}]) == "" + + +class _Teacher: + """Records the context it was handed.""" + + def __init__(self, intents: tuple[EvolutionIntent, ...] = ()) -> None: + self.intents = intents + self.saw_degraded: Any = None + + async def grade_and_propose(self, trajectory, goal="", **kwargs): + self.saw_degraded = kwargs.get("degraded_capabilities") + return type("V", (), {"grades": (), "intents": self.intents})() + + +class _OldTeacher: + """A teacher predating the extra context, to prove the contract stays open.""" + + def __init__(self) -> None: + self.called = False + + async def grade_and_propose(self, trajectory, goal=""): + self.called = True + return type("V", (), {"grades": (), "intents": ()})() + + +class _Intake: + def __init__(self, admit: bool = True) -> None: + self.admit = admit + self.results: list[Any] = [] + + def observe_result(self, result, **kwargs): + self.results.append(result) + return {"observation_id": "o1"} if self.admit else None + + def requirements(self, *, min_count: int = 1, limit: int = 50): + return () + + +@pytest.mark.asyncio +async def test_the_driver_passes_degradation_facts_to_the_teacher(): + teacher = _Teacher() + facts = ({"capability": "chat.reply", "plugin_id": "chat_reply_v1", "failure_streak": 2},) + driver = WorldModelEvolutionDriver( + teacher=teacher, intake=_Intake(), degraded_capabilities=lambda: facts + ) + + await driver.drive([{"action": "reply"}], "reply in the thread") + + assert teacher.saw_degraded == facts + + +@pytest.mark.asyncio +async def test_a_teacher_that_predates_the_context_still_grades(): + """Losing the episode's grading over an unknown keyword would be a bad trade.""" + teacher = _OldTeacher() + driver = WorldModelEvolutionDriver( + teacher=teacher, intake=_Intake(), degraded_capabilities=lambda: ({"capability": "x"},) + ) + + await driver.drive([{"action": "a"}]) + + assert teacher.called is True + assert isinstance(teacher, CapabilityGapTeacher) + + +@pytest.mark.asyncio +async def test_unavailable_degradation_facts_degrade_grading_not_the_session(): + def explode(): + raise RuntimeError("store down") + + teacher = _Teacher() + driver = WorldModelEvolutionDriver( + teacher=teacher, intake=_Intake(), degraded_capabilities=explode + ) + + result = await driver.drive([{"action": "a"}]) + + assert teacher.saw_degraded == () + assert result.proposed == 0 + + +# ── T3: an admitted intent becomes a queued proposal ────────────────────────── + + +def _intent(capability: str = "chat.reply") -> EvolutionIntent: + return EvolutionIntent.create( + capability=capability, + hypothesis="the current implementation keeps failing", + confidence=0.8, + expected_effect="the reply reaches the thread", + ) + + +@pytest.mark.asyncio +async def test_an_admitted_intent_reaches_the_proposal_queue(): + """The last hop: without it an intent becomes a requirement and stops there. + + Resolution reports the capability unmet and nothing turns that into an acquisition, + which is why ``proposal_from_evolution_intent`` had no caller at all. + """ + queued: list[Any] = [] + driver = WorldModelEvolutionDriver( + teacher=_Teacher((_intent(),)), + intake=_Intake(admit=True), + proposal_sink=lambda proposal: queued.append(proposal) or proposal.proposal_id, + ) + + result = await driver.drive([{"action": "a"}]) + + assert len(queued) == 1 + assert result.queued_proposal_ids == (queued[0].proposal_id,) + assert result.to_dict()["queued"] == 1 + + +@pytest.mark.asyncio +async def test_an_unadmitted_intent_is_never_queued(): + """The opt-in gate must not be bypassable through the proposal path.""" + queued: list[Any] = [] + driver = WorldModelEvolutionDriver( + teacher=_Teacher((_intent(),)), + intake=_Intake(admit=False), + proposal_sink=lambda proposal: queued.append(proposal), + ) + + result = await driver.drive([{"action": "a"}]) + + assert result.proposed == 1, "the teacher still proposed" + assert result.admitted == 0 + assert queued == [], "not admitted must mean not queued" + + +@pytest.mark.asyncio +async def test_no_sink_means_no_proposals_and_no_error(): + driver = WorldModelEvolutionDriver(teacher=_Teacher((_intent(),)), intake=_Intake()) + result = await driver.drive([{"action": "a"}]) + assert result.queued_proposal_ids == () + + +@pytest.mark.asyncio +async def test_one_failing_sink_call_does_not_stop_the_others(): + calls: list[str] = [] + + def sink(proposal): + calls.append(proposal.plugin_id) + if len(calls) == 1: + raise RuntimeError("queue full") + return proposal.proposal_id + + driver = WorldModelEvolutionDriver( + teacher=_Teacher((_intent("chat.reply"), _intent("chat.react"))), + intake=_Intake(admit=True), + proposal_sink=sink, + ) + + result = await driver.drive([{"action": "a"}]) + + assert len(calls) == 2, "the second intent must still be attempted" + assert len(result.queued_proposal_ids) == 1 + + +# ── rival identity: the collision that would stop competition ───────────────── + + +def test_a_rival_gets_an_identity_that_can_coexist_with_the_incumbent(): + """A plugin id derived from the capability alone cannot compete with itself. + + Both a gap fill and a rival for ``chat.reply`` would be named + ``chat_reply_plugin``. Installing the second collides with the first, so the two + could never be admissible at the same time -- which is the entire purpose of + proposing a rival. + """ + detector = CapabilityGapDetector() + intent = _intent() + + gap_fill = detector.proposal_from_evolution_intent(intent) + rival = detector.proposal_from_evolution_intent(intent, incumbent="chat_reply_v1") + + assert gap_fill.plugin_id == "chat_reply_plugin" + assert rival.plugin_id != gap_fill.plugin_id + assert rival.proposed_tools[0].name != gap_fill.proposed_tools[0].name + + +def test_a_rival_records_what_it_replaces_for_the_approver(): + """An approver must see it competes with a named incumbent, not fills an empty slot.""" + rival = CapabilityGapDetector().proposal_from_evolution_intent( + _intent(), incumbent="chat_reply_v1" + ) + metadata = dict(rival.evidence[0].metadata) + assert metadata["replaces"] == "chat_reply_v1" + assert metadata["capability"] == "chat.reply" + + +def test_successive_rivals_for_one_capability_stay_distinct(): + """Otherwise the second rival overwrites the first and the trial has one arm.""" + detector = CapabilityGapDetector() + first = detector.proposal_from_evolution_intent(_intent(), incumbent="chat_reply_v1") + second = detector.proposal_from_evolution_intent(_intent(), incumbent="chat_reply_v1") + assert first.plugin_id != second.plugin_id + + +@pytest.mark.asyncio +async def test_the_driver_marks_a_rival_only_when_the_capability_is_degraded(): + """Rival versus gap fill is a registry fact, never a reading of the hypothesis.""" + queued: list[Any] = [] + driver = WorldModelEvolutionDriver( + teacher=_Teacher((_intent("chat.reply"), _intent("chat.react"))), + intake=_Intake(admit=True), + degraded_capabilities=lambda: ( + {"capability": "chat.reply", "plugin_id": "chat_reply_v1", "failure_streak": 2}, + ), + proposal_sink=lambda proposal: queued.append(proposal) or proposal.proposal_id, + ) + + await driver.drive([{"action": "a"}]) + + by_capability = { + dict(p.evidence[0].metadata)["capability"]: dict(p.evidence[0].metadata) + for p in queued + } + assert by_capability["chat.reply"]["replaces"] == "chat_reply_v1" + assert "replaces" not in by_capability["chat.react"], "an absent provider is a gap" + + +def test_a_rival_cannot_widen_the_risk_ceiling(): + """More autonomy than filling a gap, so the clamp must still hold.""" + intent = EvolutionIntent.create( + capability="chat.reply", + hypothesis="needs shell access to work properly", + confidence=0.9, + max_risk_level="external", + ) + rival = CapabilityGapDetector().proposal_from_evolution_intent( + intent, incumbent="chat_reply_v1" + ) + assert rival.risk_level != "external" + assert dict(rival.evidence[0].metadata)["requested_max_risk_level"] == "external" + + +# ── the whole chain, against the real store ─────────────────────────────────── + + +@pytest.mark.asyncio +async def test_the_trigger_chain_survives_the_real_observation_store(tmp_path): + """T1 to T3 with the durable store in the middle, which is where it broke. + + Every unit above passes with a fake intake. The real store persists only an + allow-listed set of payload keys, and ``plugin_id``/``failure_streak`` were not on + it -- so the degradation facts arrived carrying ``None`` for both. The teacher then + could not tell what would be replaced, and proposal identity fell back to the + capability-derived name that collides with the incumbent. Nothing raised. + """ + from leapflow.domain.evolution_intent import WORLD_MODEL_INTENT + + store = JsonCapabilityObservationStore(tmp_path / "obs.json") + service = CapabilityObservationService( + store, + classifier=CapabilityEvidenceClassifier.from_kinds( + ["unknown_tool", CAPABILITY_DEGRADED, WORLD_MODEL_INTENT] + ), + ) + declared = {"chat_reply_v1": ("chat.reply",)} + + def sink( + *, plugin_id: str, failure_streak: int, trust_level: str, failure_class: str = "" + ) -> None: + for capability in declared.get(plugin_id, ()): + service.observe_result( + { + "error_type": CAPABILITY_DEGRADED, + "capability": capability, + "plugin_id": plugin_id, + "failure_streak": failure_streak, + "trust_level": trust_level, + "failure_class": failure_class, + } + ) + + governor = LifecycleGovernor( + proposal_queue=_Queue(), outcome_store=_Outcomes(2), degradation_sink=sink + ) + await _record(governor, ok=False) + + # The service's own reader, so the filter and the environment tag are applied + # once rather than re-derived by every consumer. + degraded = service.degraded_capabilities + + # The incumbent must survive the round trip through the store. + facts = degraded() + assert facts and facts[0]["plugin_id"] == "chat_reply_v1", facts + assert str(facts[0]["failure_streak"]) == "2", facts + + teacher = _Teacher((_intent("chat.reply"),)) + queued: list[Any] = [] + driver = WorldModelEvolutionDriver( + teacher=teacher, + intake=service, + degraded_capabilities=degraded, + proposal_sink=lambda proposal: queued.append(proposal) or proposal.proposal_id, + ) + + result = await driver.drive([{"action": "reply"}], "reply in the thread") + + assert result.admitted == 1 and len(result.queued_proposal_ids) == 1 + rival = queued[0] + assert dict(rival.evidence[0].metadata)["replaces"] == "chat_reply_v1" + + # And it can coexist with what a gap fill for the same capability would be named. + gap_fill = CapabilityGapDetector().proposal_from_evolution_intent(_intent("chat.reply")) + assert rival.plugin_id != gap_fill.plugin_id + + # The degradation record is not erased by the incumbent still being found. + service.resolve_capability("chat.reply", reason="incumbent serves it") + assert degraded(), "degradation evidence must outlive a met resolution" + + +@pytest.mark.asyncio +async def test_a_partially_admitted_batch_queues_only_what_was_admitted(): + """The side door the opt-in gate exists to prevent. + + Collecting only the admitted observation *ids* was enough to count admissions and + not enough to act on them: queueing then received every intent whenever any one of + them was admitted, so a rejected hypothesis reached the proposal queue anyway. It is + invisible today because the shipped intake accepts a kind wholesale, and becomes a + real bypass the moment admission is decided per intent. + """ + + class _Selective: + """Admits only the capability it was told to.""" + + def __init__(self, allow: str) -> None: + self.allow = allow + self.seen: list[str] = [] + + def observe_result(self, result, **kwargs): + capability = str((result or {}).get("capability") or "") + self.seen.append(capability) + return {"observation_id": f"o-{capability}"} if capability == self.allow else None + + def requirements(self, *, min_count: int = 1, limit: int = 50): + return () + + intake = _Selective("chat.reply") + queued: list[Any] = [] + driver = WorldModelEvolutionDriver( + teacher=_Teacher((_intent("chat.reply"), _intent("chat.react"))), + intake=intake, + proposal_sink=lambda proposal: queued.append(proposal) or proposal.proposal_id, + ) + + result = await driver.drive([{"action": "a"}]) + + assert intake.seen == ["chat.reply", "chat.react"], "both were offered to the gate" + assert result.proposed == 2 and result.admitted == 1 + assert len(queued) == 1, "only the admitted intent may be queued" + assert dict(queued[0].evidence[0].metadata)["capability"] == "chat.reply" + + +# ── A4-A6: the facts must be classified, filtered and environment-tagged ────── + + +def _degraded_service(tmp_path): + from leapflow.learning.capability_observation import CAPABILITY_DEGRADED + + store = JsonCapabilityObservationStore(tmp_path / "obs.json") + service = CapabilityObservationService( + store, + classifier=CapabilityEvidenceClassifier.from_kinds( + ["unknown_tool", CAPABILITY_DEGRADED] + ), + ) + return store, service + + +def _env(): + from leapflow.domain.environment_fingerprint import EnvironmentFingerprint + from leapflow.domain.platform import Capability, PlatformID, PlatformManifest + + return EnvironmentFingerprint.from_platform_manifest( + PlatformManifest(PlatformID.DARWIN_15, "15.0", frozenset({Capability.FILE_OPS})) + ) + + +def _observe(service, capability: str, plugin: str, failure_class: str, environment=None): + service.observe_result( + { + "error_type": CAPABILITY_DEGRADED, + "capability": capability, + "plugin_id": plugin, + "failure_streak": 2, + "failure_class": failure_class, + }, + environment=environment, + ) + + +def test_a_retry_owned_failure_never_reaches_the_teacher(tmp_path): + """A timeout is the retry layer's business, and the teacher has one lever: rebuild. + + Forwarding transients would ask a hindsight evaluator to adjudicate something that + already resolved itself, and the only verdict that changes anything is the most + expensive response in the system. + """ + from leapflow.learning.capability_observation import RETRY_OWNED_FAILURE_CLASSES + + store, service = _degraded_service(tmp_path) + _observe(service, "chat.reply", "chat_v1", "affordance_removed") + _observe(service, "net.fetch", "flaky_v1", "timeout") + + assert len(store.unresolved()) == 2, "both are recorded" + reaching = {f["capability"] for f in service.degraded_capabilities()} + assert reaching == {"chat.reply"}, reaching + assert "timeout" in RETRY_OWNED_FAILURE_CLASSES + + +def test_the_failure_class_survives_persistence(tmp_path): + """It was not on the store's allow-list, so it was silently dropped -- twice now. + + Without it the fact reads "failed twice" and the retry-owned classes cannot be + filtered at all, because the filter has nothing to filter on. + """ + from leapflow.storage.capability_observation_store import _OBSERVATION_FIELDS + + assert "failure_class" in _OBSERVATION_FIELDS + + _, service = _degraded_service(tmp_path) + _observe(service, "chat.reply", "chat_v1", "affordance_removed") + fact = service.degraded_capabilities()[0] + assert fact["failure_class"] == "affordance_removed" + + +def test_one_environment_change_is_recognisable_as_one_cause(tmp_path): + """N capabilities bound to the same removed affordance are one change, not N. + + Without the fingerprint the teacher answers N times and can propose N rebuilds + where the truth is one root cause and usually one rebind. + """ + from leapflow.world_model.trajectory_grader import _degraded_capability_section + + _, service = _degraded_service(tmp_path) + environment = _env() + _observe(service, "chat.reply", "chat_v1", "affordance_removed", environment) + _observe(service, "mail.send", "mail_v1", "affordance_removed", environment) + + facts = service.degraded_capabilities() + assert len({f["environment"].get("fingerprint_id") for f in facts}) == 1 + + section = _degraded_capability_section(facts) + assert "one change rather than several" in section + assert "affordance_removed" in section, "the class must be visible to the teacher" + + +def test_governance_reports_the_failure_class_it_was_given(): + """The streak alone cannot say what kind of failure it was.""" + import asyncio + + seen: list[dict[str, Any]] = [] + governor = _governor(streak=2, sink=lambda **kw: seen.append(kw)) + asyncio.run( + governor.record_outcome( + proposal_id="p1", + plugin_id="chat_reply_v1", + tool_name="chat_reply_v1", + ok=False, + failure_class="affordance_removed", + ) + ) + assert seen[0]["failure_class"] == "affordance_removed" + + +# ── the fact the rebind/acquire choice is defined by ────────────────────────── + + +def test_the_absence_of_an_alternative_is_stated_not_omitted(): + """"No alternative exists" is the positive evidence for acquire. + + The action space defines ``rebind`` as "another installed capability already covers + this" and ``acquire`` as "nothing does" -- and the teacher was shown neither. It saw a + flat list of global capability names and had to guess. Measured on a real model: + ``rebind`` on 3 of 3 trials of a unit whose candidate set had exactly one entry. + + Omitting the line would be worse than saying nothing, because silence reads as "not + checked" rather than "checked and there are none". + """ + from leapflow.world_model.trajectory_grader import _degraded_capability_section + + section = _degraded_capability_section( + [{"capability": "chat.reply", "plugin_id": "chat_reply_v1", + "failure_streak": 3, "alternatives": ()}] + ) + assert "no other installed provider offers this capability" in section + + +def test_a_usable_alternative_is_named_and_an_unusable_one_is_qualified(): + from leapflow.world_model.trajectory_grader import _degraded_capability_section + + section = _degraded_capability_section( + [{ + "capability": "chat.reply", + "plugin_id": "chat_reply_v1", + "failure_streak": 3, + "alternatives": ( + {"plugin_id": "a", "tool_name": "chat_reply_v2", "fits_here": True, + "requires": ("app.chat.v2",)}, + {"plugin_id": "b", "tool_name": "chat_reply_v0", "fits_here": False, + "requires": ("app.chat.v0",)}, + ), + }] + ) + assert "chat_reply_v2" in section + assert "chat_reply_v0 (needs app.chat.v0)" in section + assert "one of these could take over" in section + + +def test_alternatives_that_all_misfit_say_so(): + """Existing but unusable is not the same as absent, and warrants a different action.""" + from leapflow.world_model.trajectory_grader import _degraded_capability_section + + section = _degraded_capability_section( + [{ + "capability": "chat.reply", "plugin_id": "v1", "failure_streak": 3, + "alternatives": ( + {"plugin_id": "b", "tool_name": "chat_reply_v0", "fits_here": False, + "requires": ("app.chat.v0",)}, + ), + }] + ) + assert "none of these can run in this environment" in section + + +def test_one_degradation_is_not_told_it_might_be_several(): + """The shared-environment hint asks the teacher to reconcile a single fact.""" + from leapflow.world_model.trajectory_grader import _degraded_capability_section + + one = _degraded_capability_section( + [{"capability": "chat.reply", "plugin_id": "v1", "failure_streak": 2, + "environment": {"fingerprint_id": "fp"}}] + ) + assert "one change rather than several" not in one + + two = _degraded_capability_section( + [ + {"capability": "chat.reply", "plugin_id": "v1", "failure_streak": 2, + "environment": {"fingerprint_id": "fp"}}, + {"capability": "mail.send", "plugin_id": "v2", "failure_streak": 2, + "environment": {"fingerprint_id": "fp"}}, + ] + ) + assert "one change rather than several" in two + + +def test_an_unknown_environment_does_not_mark_every_alternative_unusable(): + """Undescribed must read as "cannot judge", not "nothing is available". + + The other reading would mark every alternative a misfit and push every verdict toward + acquire -- the most expensive branch -- for the sole reason that the environment could + not be described. + """ + from leapflow.learning.degradation_sink import build_alternatives_provider + from leapflow.plugins.protocol import ToolMetadata + + tool = ToolMetadata( + name="chat_reply_v2", + description="reply", + parameters_schema={"type": "object", "properties": {}}, + handler=lambda **kwargs: None, + x_leapflow={"category": "chat", "risk_level": "read_only"}, + provides_capabilities=("chat.reply",), + requires_environment_affordances=("app.chat.v2",), + ) + registry = SimpleNamespace( + plugins={"v2": SimpleNamespace(tools=[tool])}, + tool_owners={"chat_reply_v2": "v2"}, + tool_handlers={"chat_reply_v2": tool.handler}, + ) + + unknown = build_alternatives_provider( + registry_provider=lambda: registry, affordances_provider=lambda: () + )("chat.reply", "v1") + assert unknown and unknown[0]["fits_here"] is True + + absent = build_alternatives_provider( + registry_provider=lambda: registry, affordances_provider=lambda: ("app.chat.v1",) + )("chat.reply", "v1") + assert absent and absent[0]["fits_here"] is False + + +def test_the_incumbent_is_not_offered_as_its_own_alternative(): + """Rebinding to the thing that is failing is not an option.""" + from leapflow.learning.degradation_sink import build_alternatives_provider + from leapflow.plugins.protocol import ToolMetadata + + tool = ToolMetadata( + name="chat_reply_v1", + description="reply", + parameters_schema={"type": "object", "properties": {}}, + handler=lambda **kwargs: None, + x_leapflow={"category": "chat", "risk_level": "read_only"}, + provides_capabilities=("chat.reply",), + ) + registry = SimpleNamespace( + plugins={"v1": SimpleNamespace(tools=[tool])}, + tool_owners={"chat_reply_v1": "v1"}, + tool_handlers={"chat_reply_v1": tool.handler}, + ) + provider = build_alternatives_provider(registry_provider=lambda: registry) + + assert provider("chat.reply", "v1") == () + assert len(provider("chat.reply", "")) == 1 diff --git a/tests/test_capability_requirement_and_environment.py b/tests/test_capability_requirement_and_environment.py index 85343b16..328e4972 100644 --- a/tests/test_capability_requirement_and_environment.py +++ b/tests/test_capability_requirement_and_environment.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for structured adaptive capability requirements and environments.""" from __future__ import annotations diff --git a/tests/test_capability_resolver.py b/tests/test_capability_resolver.py index e503861b..40c98b8c 100644 --- a/tests/test_capability_resolver.py +++ b/tests/test_capability_resolver.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for deterministic adaptive capability resolution.""" from __future__ import annotations @@ -157,13 +158,23 @@ def test_risk_limit_excludes_candidate() -> None: def test_tie_can_be_resolved_by_optional_arbiter() -> None: + """The arbiter now belongs to the greedy policy, which is where ties exist. + + A tie-break is a property of greedy scoring: under a sampling policy two + candidates never tie, because each draw is continuous. Leaving the hook on the + resolver would have made every future policy inherit something meaningless to it. + """ + from leapflow.plugins._builtin_policies import GreedyPolicy + req = _req("json.pretty") candidates = ( _candidate("a", "tool_a", provides=("json.pretty",)), _candidate("b", "tool_b", provides=("json.pretty",)), ) - resolution = CapabilityResolver(arbiter=_TieArbiter("tool_b")).resolve_one( + resolution = CapabilityResolver( + policy=GreedyPolicy(arbiter=_TieArbiter("tool_b")) + ).resolve_one( req, candidates, ResolverContext(environment=_env(Capability.FILE_OPS)), @@ -172,6 +183,9 @@ def test_tie_can_be_resolved_by_optional_arbiter() -> None: assert resolution.selected is not None assert resolution.selected.candidate.tool_name == "tool_b" assert resolution.arbitration_used is True + assert resolution.policy_id == "greedy" + # An arbitrated tie is still the argmax, so it is not an exploration. + assert resolution.explored is False def test_no_arbiter_tie_uses_stable_order() -> None: diff --git a/tests/test_cli_discovery.py b/tests/test_cli_discovery.py index 08305448..61dec120 100644 --- a/tests/test_cli_discovery.py +++ b/tests/test_cli_discovery.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for CLI help-based command discovery.""" from __future__ import annotations diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 8ae85fd4..3d9f62be 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations import argparse diff --git a/tests/test_cli_hardware.py b/tests/test_cli_hardware.py index cd95a908..48aca2e3 100644 --- a/tests/test_cli_hardware.py +++ b/tests/test_cli_hardware.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """`leap hw` — CLI subcommands and pause/resume RPC (Phase 1.4). Two planes are exercised separately, because they route differently: diff --git a/tests/test_cli_ndjson_event_source.py b/tests/test_cli_ndjson_event_source.py index 3088bded..40e8bcf4 100644 --- a/tests/test_cli_ndjson_event_source.py +++ b/tests/test_cli_ndjson_event_source.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for CliNdjsonEventSource — generic CLI NDJSON subprocess manager.""" from __future__ import annotations diff --git a/tests/test_code_tools.py b/tests/test_code_tools.py index 0cfffa33..0c18c9b7 100644 --- a/tests/test_code_tools.py +++ b/tests/test_code_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the P0 coding built-in tools: code_search, file_find, edit_file. Handlers are pure async functions (params dict -> result dict); exercised diff --git a/tests/test_coevolution_observations.py b/tests/test_coevolution_observations.py index ed892d40..6af4c92e 100644 --- a/tests/test_coevolution_observations.py +++ b/tests/test_coevolution_observations.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """A-r1 / A-r2: the sweep's inputs are produced by the real production paths. Phase A gave the sweep a call site but nothing fed it, so it correctly emitted three diff --git a/tests/test_coevolution_sweep_wiring.py b/tests/test_coevolution_sweep_wiring.py index 3f5930ef..02469e5a 100644 --- a/tests/test_coevolution_sweep_wiring.py +++ b/tests/test_coevolution_sweep_wiring.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Phase A: the co-evolution capabilities are wired, and the wiring is driven. The prior round shipped `CapabilityEffectVerifier`, `QuarantineCandidateTracker` @@ -231,6 +232,19 @@ async def run_sweep(self): return await Context._run_coevolution_sweep(self) + def _resolve_lifecycle_governor(self): + """Bound because the production hook resolves the governor through it. + + The hook used to read ``self.lifecycle_governor`` directly -- an attribute nothing + in production ever assigned, so every session swept with ``governor=None``. It now + goes through a resolver that honours an injected governor first and builds one from + the profile layout otherwise, and a host driving the real hook has to expose the + same surface or it would exercise the broad ``except`` instead of the code. + """ + from leapflow.cli.context import Context + + return Context._resolve_lifecycle_governor(self) + def test_production_sweep_hook_builds_and_runs_a_real_sweep(): """Drives `_run_coevolution_sweep` itself, not a hand-made CoevolutionSweep. diff --git a/tests/test_compatibility_assessment.py b/tests/test_compatibility_assessment.py index 77c28b57..0fda73fe 100644 --- a/tests/test_compatibility_assessment.py +++ b/tests/test_compatibility_assessment.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Comprehensive tests for the Plugin Compatibility Assessment Engine (P0). Tests cover: diff --git a/tests/test_concurrent_workspace_governance.py b/tests/test_concurrent_workspace_governance.py index 9f719085..b4611b54 100644 --- a/tests/test_concurrent_workspace_governance.py +++ b/tests/test_concurrent_workspace_governance.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """F7 / F8: concurrent workspaces, and the fiber lifecycle of the changed contracts. **F7 (MANDATORY).** This work introduced process-global governance state, so AGENTS.md diff --git a/tests/test_config_and_path_contracts.py b/tests/test_config_and_path_contracts.py index 6e67c933..c4aaaa7a 100644 --- a/tests/test_config_and_path_contracts.py +++ b/tests/test_config_and_path_contracts.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """End-to-end guards for the config control plane and the path tree contract. AGENTS.md devotes a whole section to these, but coverage was scattered: each diff --git a/tests/test_config_capability_tools.py b/tests/test_config_capability_tools.py index 831a4566..0229c129 100644 --- a/tests/test_config_capability_tools.py +++ b/tests/test_config_capability_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Guards for the config capability and the path boundaries around it. The scenario these lock down: a user asks to change ``llm.model`` from inside an @@ -165,6 +166,40 @@ def test_config_set_never_echoes_a_secret(cfg_home) -> None: assert "sk-must-not-echo" not in str(result) +def test_config_set_declares_the_value_the_key_now_holds(cfg_home) -> None: + """The effect channel: what was observed, not what was asked for. + + A capability that claims to change configuration can only be *verified* if the + handler says what the key now holds. Without it the verdict abstains, and the + board reports an abstention it cannot attribute. + """ + result = asyncio.run( + config_tools.config_set_handler({"key": "llm.model", "value": "qwen3.8-max"}) + ) + + assert result["ok"] is True + assert "qwen3.8-max" in result["observed_effect"] + assert "llm.model" in result["observed_effect"] + + +def test_the_effect_declaration_redacts_a_secret_like_the_payload(cfg_home) -> None: + """An effect declaration is transcript too, so it obeys the same redaction rule. + + The value is withheld while the change is still reported -- otherwise a secret + write would be indistinguishable from a tool that said nothing, and would be + counted as an abstention rather than a confirmation. + """ + result = asyncio.run( + config_tools.config_set_handler({"key": "llm.api_key", "value": "sk-must-not-echo"}) + ) + + assert result["ok"] is True + effect = result["observed_effect"] + assert "sk-must-not-echo" not in effect + assert "llm.api_key" in effect + assert "updated" in effect + + def test_config_set_requires_key_and_value(cfg_home) -> None: missing_value = asyncio.run(config_tools.config_set_handler({"key": "llm.model"})) missing_key = asyncio.run(config_tools.config_set_handler({"value": "x"})) diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index a73f4d60..f964d916 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations from leapflow.config_loader import load_config_bundle diff --git a/tests/test_context_budget_scaling.py b/tests/test_context_budget_scaling.py index a7ad5246..c53b9831 100644 --- a/tests/test_context_budget_scaling.py +++ b/tests/test_context_budget_scaling.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Contracts for context budget resolution and truncation-chain scaling. Two problems these pin down: diff --git a/tests/test_context_disclosure.py b/tests/test_context_disclosure.py index 182ef366..09f3de45 100644 --- a/tests/test_context_disclosure.py +++ b/tests/test_context_disclosure.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations from leapflow.engine.context_disclosure import ( diff --git a/tests/test_context_focus.py b/tests/test_context_focus.py index 52326ae8..a2b3e8a3 100644 --- a/tests/test_context_focus.py +++ b/tests/test_context_focus.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations from leapflow.engine.context_focus import ( diff --git a/tests/test_context_governance.py b/tests/test_context_governance.py index 06964256..ccbd2c4d 100644 --- a/tests/test_context_governance.py +++ b/tests/test_context_governance.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations from typing import Any diff --git a/tests/test_context_misbinding_regression.py b/tests/test_context_misbinding_regression.py index 79e42313..cfd9d2a2 100644 --- a/tests/test_context_misbinding_regression.py +++ b/tests/test_context_misbinding_regression.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations from conftest import StubLLM, make_settings diff --git a/tests/test_cua_client_mapping.py b/tests/test_cua_client_mapping.py index 07f42784..3c4272be 100644 --- a/tests/test_cua_client_mapping.py +++ b/tests/test_cua_client_mapping.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """CuaDriverClient method→tool mapping and timeout resolution. Locks the cua-driver wire contract (verified against 0.6.8): get_window_state diff --git a/tests/test_cv_plugins.py b/tests/test_cv_plugins.py index 9d57549d..e5d0f829 100644 --- a/tests/test_cv_plugins.py +++ b/tests/test_cv_plugins.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for CV algorithm plugins and the CVProcessor Protocol (Fix D4). Covers the default registry contents, Protocol conformance, and both the diff --git a/tests/test_daemon_event_loop_blocking.py b/tests/test_daemon_event_loop_blocking.py index 5d7f4afe..cf81d8ce 100644 --- a/tests/test_daemon_event_loop_blocking.py +++ b/tests/test_daemon_event_loop_blocking.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Regression tests for the daemon event-loop permanent-blocking fix. Root cause chain (observed as a 43-minute daemon freeze where every RPC diff --git a/tests/test_daemon_isolation.py b/tests/test_daemon_isolation.py index 845cf86d..c4aeb2ec 100644 --- a/tests/test_daemon_isolation.py +++ b/tests/test_daemon_isolation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Daemon isolation tests — memory session scoping and approval queue hygiene. Validates Phase 0.1/0.2 fixes: diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index 17ce4b0a..47321a42 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations import asyncio diff --git a/tests/test_daemon_transport.py b/tests/test_daemon_transport.py index 18c57701..f7ea142a 100644 --- a/tests/test_daemon_transport.py +++ b/tests/test_daemon_transport.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic tests for cross-platform daemon transport helpers.""" from __future__ import annotations diff --git a/tests/test_darwin_adapter.py b/tests/test_darwin_adapter.py index 6a6d956c..aee69dc8 100644 --- a/tests/test_darwin_adapter.py +++ b/tests/test_darwin_adapter.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the Darwin platform adapter. Verifies that each port exposed by DarwinPerceptionAdapter and diff --git a/tests/test_dashboard_domains.py b/tests/test_dashboard_domains.py index a3248580..470fb0cf 100644 --- a/tests/test_dashboard_domains.py +++ b/tests/test_dashboard_domains.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic tests for P4 domain templates and the custom-component escape hatch.""" from __future__ import annotations diff --git a/tests/test_dashboard_i18n_static.py b/tests/test_dashboard_i18n_static.py index 55309266..826cf51c 100644 --- a/tests/test_dashboard_i18n_static.py +++ b/tests/test_dashboard_i18n_static.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Static regression guards for LeapBoard frontend i18n coverage. There is no JS test runner in this repository yet, so these tests protect the diff --git a/tests/test_dashboard_launcher.py b/tests/test_dashboard_launcher.py index bc8e6ec2..4cf422c6 100644 --- a/tests/test_dashboard_launcher.py +++ b/tests/test_dashboard_launcher.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic tests for the dashboard launcher and server action dispatch. No aiohttp required: the launcher is dependency-free and DashboardServer's diff --git a/tests/test_dashboard_sdui.py b/tests/test_dashboard_sdui.py index 81770c57..2bbefc6f 100644 --- a/tests/test_dashboard_sdui.py +++ b/tests/test_dashboard_sdui.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic tests for the dashboard SDUI core: ViewSpec, templates, intent.""" from __future__ import annotations diff --git a/tests/test_dashboard_view.py b/tests/test_dashboard_view.py index 365ce7b1..3daa0ff0 100644 --- a/tests/test_dashboard_view.py +++ b/tests/test_dashboard_view.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic tests for the dashboard view builder and WebSocket fan-out hub.""" from __future__ import annotations diff --git a/tests/test_dashboard_watch_rpc.py b/tests/test_dashboard_watch_rpc.py index 9447f81c..fa39d165 100644 --- a/tests/test_dashboard_watch_rpc.py +++ b/tests/test_dashboard_watch_rpc.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic tests for the watch RPC surface and the /board command handler. No network, no LLM, no full Context: the daemon service and slash handler are diff --git a/tests/test_deferred_init_responsiveness.py b/tests/test_deferred_init_responsiveness.py index 9db118f9..f2f0919a 100644 --- a/tests/test_deferred_init_responsiveness.py +++ b/tests/test_deferred_init_responsiveness.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Regression tests for the first-command timeout fix. Root cause: ``Context.initialize_deferred()`` was a ~550-line async function diff --git a/tests/test_degradation_feedback_loop.py b/tests/test_degradation_feedback_loop.py new file mode 100644 index 00000000..59d36ef7 --- /dev/null +++ b/tests/test_degradation_feedback_loop.py @@ -0,0 +1,488 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Phase D: close the loop, and make it actually run in production. + +The chain built in T1-T3 and C1-C3 was complete on paper and inert in fact. +``self.lifecycle_governor`` was never assigned anywhere, so the sweep resolved the +governor to ``None``, ``record_outcome`` was never called, and the degradation evidence +that the teacher prompt, the challenger identity, and the proposal path were all built to +consume was never produced. The suite was green throughout, because every unit test +constructs the governor itself -- which is exactly the shape of defect a unit test cannot +see: the wiring, not the logic. + +With evidence flowing, the loop still needed closing. The teacher was shown the same +degradation every session and could only ever reach the same conclusion, having no way to +learn that its previous answer did not work. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Any + +import pytest + +from leapflow.domain.adaptation_verdict import AdaptationVerdict +from leapflow.engine.engine import AgentEngine +from leapflow.learning.capability_observation import ( + CAPABILITY_DEGRADED, + CapabilityEvidenceClassifier, + CapabilityObservationService, +) +from leapflow.learning.degradation_sink import ( + build_degradation_sink, + declared_capabilities_by_plugin, +) +from leapflow.learning.world_model_driver import WorldModelEvolutionDriver +from leapflow.plugins.lifecycle_governor import LifecycleGovernor +from leapflow.plugins.protocol import ToolMetadata +from leapflow.storage.capability_observation_store import JsonCapabilityObservationStore +from leapflow.storage.distilled_knowledge_store import JsonDistilledKnowledgeStore +from leapflow.world_model.trajectory_grader import ( + TeacherVerdict, + _degraded_capability_section, +) + + +def _tool(name: str, *capabilities: str) -> ToolMetadata: + return ToolMetadata( + name=name, + description=name, + parameters_schema={"type": "object", "properties": {}}, + handler=lambda **kwargs: None, + x_leapflow={"category": "chat", "risk_level": "read_only"}, + provides_capabilities=tuple(capabilities), + ) + + +def _registry(**plugins: tuple[ToolMetadata, ...]) -> Any: + # First-wins, as the registry arbitrates it: the incumbent keeps the name and the + # challenger is recorded as a conflict. A dict comprehension would let the last + # declaration overwrite the first, which is the opposite of production. + owners: dict[str, str] = {} + for pid, tools in plugins.items(): + for t in tools: + owners.setdefault(t.name, pid) + handlers = {t.name: t.handler for tools in plugins.values() for t in tools} + return SimpleNamespace( + plugins={pid: SimpleNamespace(tools=list(tools)) for pid, tools in plugins.items()}, + tool_owners=owners, + tool_handlers=handlers, + ) + + +class _Queue: + def update(self, *args: Any, **kwargs: Any) -> None: + return None + + +class _Outcomes: + def __init__(self, streak: int = 0) -> None: + self.streak = streak + + def add_outcome(self, **kwargs: Any) -> None: + return None + + def failure_streak(self, plugin_id: str) -> int: + return self.streak + + +@pytest.fixture +def wired(tmp_path): + """Governor, observation service and knowledge store, wired as production wires them.""" + service = CapabilityObservationService( + JsonCapabilityObservationStore(tmp_path / "obs.json"), + classifier=CapabilityEvidenceClassifier.from_kinds( + ["unknown_tool", CAPABILITY_DEGRADED] + ), + ) + knowledge = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + registry = _registry(chat_reply_v1=(_tool("chat_reply", "chat.reply"),)) + outcomes = _Outcomes() + governor = LifecycleGovernor( + proposal_queue=_Queue(), + outcome_store=outcomes, + degradation_sink=build_degradation_sink( + intake=service, + registry_provider=lambda: registry, + knowledge_store=knowledge, + ), + ) + return SimpleNamespace( + service=service, knowledge=knowledge, governor=governor, outcomes=outcomes + ) + + +def _record(governor: Any, *, ok: bool, failure_class: str = "") -> Any: + return asyncio.run( + governor.record_outcome( + proposal_id="p1", + plugin_id="chat_reply_v1", + tool_name="chat_reply", + ok=ok, + failure_class=failure_class, + ) + ) + + +# ── D0: the translation from plugin health to capability evidence ────────────── + + +def test_plugin_health_becomes_capability_evidence(wired): + """The governor holds no registry, so this translation is why the sink exists. + + A capability is what a rival can be built for and what knowledge attaches to; a + plugin id is neither. + """ + wired.outcomes.streak = 2 + _record(wired.governor, ok=False, failure_class="affordance_removed") + + facts = wired.service.degraded_capabilities() + assert len(facts) == 1 + assert facts[0]["capability"] == "chat.reply" + assert facts[0]["plugin_id"] == "chat_reply_v1" + assert facts[0]["failure_class"] == "affordance_removed" + + +def test_capabilities_come_from_declarations_through_the_live_catalog(): + """Delegating to the resolver's builder keeps two filters this must not lose. + + A shadowed tool is not live (first-wins arbitration) and an unbound tool is not + callable, so re-walking the registry would degrade a plugin under a capability it + does not actually serve in this process. + """ + registry = _registry( + a=(_tool("shared", "chat.reply"),), + b=(_tool("shared", "chat.react"),), # loses the name to `a` + c=(_tool("undeclared"),), # declares nothing + ) + declared = declared_capabilities_by_plugin(registry) + + assert declared.get("a") == ("chat.reply",) + assert "b" not in declared, "a shadowed tool is not live" + assert "c" not in declared, "a plugin declaring nothing cannot be degraded as one" + + +def test_a_plugin_declaring_no_capability_reports_nothing(tmp_path): + """Inventing a name from the plugin id would put a fabricated capability in front + of the teacher, which is worse than reporting nothing.""" + service = CapabilityObservationService( + JsonCapabilityObservationStore(tmp_path / "obs.json"), + classifier=CapabilityEvidenceClassifier.from_kinds([CAPABILITY_DEGRADED]), + ) + sink = build_degradation_sink( + intake=service, + registry_provider=lambda: _registry(chat_reply_v1=(_tool("chat_reply"),)), + ) + sink(plugin_id="chat_reply_v1", failure_streak=2, trust_level="DRAFT") + + assert service.degraded_capabilities() == () + + +def test_the_registry_is_resolved_per_report_not_captured(tmp_path): + """Plugins are installed and reloaded at runtime, so a snapshot goes stale.""" + service = CapabilityObservationService( + JsonCapabilityObservationStore(tmp_path / "obs.json"), + classifier=CapabilityEvidenceClassifier.from_kinds([CAPABILITY_DEGRADED]), + ) + registries = [_registry(), _registry(late=(_tool("late_tool", "late.thing"),))] + sink = build_degradation_sink( + intake=service, registry_provider=lambda: registries[-1] + ) + + sink(plugin_id="late", failure_streak=2, trust_level="DRAFT") + assert {f["capability"] for f in service.degraded_capabilities()} == {"late.thing"} + + +def test_a_broken_registry_does_not_break_governance(tmp_path): + def _explode(): + raise RuntimeError("registry mid-reload") + + sink = build_degradation_sink(intake=object(), registry_provider=_explode) + sink(plugin_id="anything", failure_streak=2, trust_level="DRAFT") # must not raise + + +# ── D1: the feedback edge ────────────────────────────────────────────────────── + + +def test_the_teacher_is_shown_what_it_concluded_last_time(wired): + """Without this the loop is open: the same evidence can only produce the same answer.""" + wired.outcomes.streak = 2 + _record(wired.governor, ok=False, failure_class="affordance_removed") + wired.knowledge.record( + AdaptationVerdict.create( + "absorb", "chat.reply", "the send control moved to the toolbar" + ) + ) + + seen: list[Any] = [] + + class _Teacher: + async def grade_and_propose(self, trajectory, goal="", **kwargs): + seen.append(kwargs.get("degraded_capabilities")) + return TeacherVerdict() + + driver = WorldModelEvolutionDriver( + teacher=_Teacher(), intake=wired.service, knowledge_store=wired.knowledge + ) + asyncio.run(driver.drive([{"action": "a"}], "reply")) + + facts = seen[0] + assert facts[0]["prior_action"] == "absorb" + assert "moved to the toolbar" in facts[0]["prior_knowledge"] + + section = _degraded_capability_section(facts) + assert "last time you answered 'absorb'" in section + + +def test_a_capability_with_no_prior_verdict_is_unchanged(wired): + """Enrichment must not invent history where there is none.""" + wired.outcomes.streak = 2 + _record(wired.governor, ok=False) + + driver = WorldModelEvolutionDriver( + teacher=SimpleNamespace(), intake=wired.service, knowledge_store=wired.knowledge + ) + facts = driver._collect_degraded() + assert facts and "prior_action" not in facts[0] + + +def test_prior_knowledge_is_history_not_a_verdict_on_the_verdict(wired): + """Knowledge outliving a failure is evidence the adaptation did not resolve it. + + Not proof the judgement was wrong: the student may never have used it, the + environment may have moved again, or this may be a different failure. Deciding + which is what the teacher is for, so the prompt must not pre-empt it. + """ + facts = ( + { + "capability": "chat.reply", + "plugin_id": "chat_reply_v1", + "failure_streak": 2, + "failure_class": "", + "prior_action": "absorb", + "prior_knowledge": "the control moved", + }, + ) + section = _degraded_capability_section(facts) + assert "last time you answered" in section + # The section must not pre-empt the judgement. It carried the pre-Phase-B binary + # ("an implementation that is wrong, or an environment that changed... report a gap + # only for the former"), which contradicted the four-action prompt it sits inside -- + # and which suppresses the environment-upgrade case the whole design exists for. + assert "Report a gap only" not in section + assert "implementation that is\nwrong" not in section + assert "which of the four actions the evidence" in section + + +# ── D2: recovery retires knowledge ───────────────────────────────────────────── + + +def test_recovery_retires_the_knowledge_that_described_the_failure(wired): + """The one retirement neither supersession nor expiry covers. + + No newer verdict is coming precisely because there is no longer anything wrong, so + without this the knowledge outlives the failure and misleads every later session. + """ + wired.knowledge.record( + AdaptationVerdict.create("absorb", "chat.reply", "the control is missing") + ) + assert wired.knowledge.count() == 1 + + wired.outcomes.streak = 0 + _record(wired.governor, ok=True) + + assert wired.knowledge.count() == 0, "a zero streak is the retirement signal" + + +def test_a_still_failing_capability_keeps_its_knowledge(wired): + wired.knowledge.record( + AdaptationVerdict.create("absorb", "chat.reply", "the control is missing") + ) + wired.outcomes.streak = 2 + _record(wired.governor, ok=False) + + assert wired.knowledge.count() == 1 + + +def test_retirement_without_a_knowledge_store_is_harmless(tmp_path): + service = CapabilityObservationService( + JsonCapabilityObservationStore(tmp_path / "obs.json"), + classifier=CapabilityEvidenceClassifier.from_kinds([CAPABILITY_DEGRADED]), + ) + sink = build_degradation_sink( + intake=service, + registry_provider=lambda: _registry(v1=(_tool("t", "chat.reply"),)), + ) + sink(plugin_id="v1", failure_streak=0, trust_level="DRAFT") # must not raise + + +# ── D3: the recommendation reaches the student ───────────────────────────────── + + +def _reader(store: Any) -> AgentEngine: + engine = AgentEngine.__new__(AgentEngine) + engine._knowledge_store = store + engine._environment_fingerprint_id = "" + engine._settings = SimpleNamespace(distilled_knowledge_limit=12) + return engine + + +def test_a_rebind_target_tells_the_student_what_to_prefer(tmp_path): + """Stored and never read by anyone is the failure mode this closes. + + The student would be told a problem exists without being told the answer that was + already worked out. + """ + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record( + AdaptationVerdict.create( + "rebind", "chat.reply", "the app is now v3", target="chat_reply_v3" + ) + ) + + block = _reader(store)._distilled_knowledge_context() + assert "Prefer chat_reply_v3." in block + + +def test_an_escalation_target_names_what_a_person_must_do(tmp_path): + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record( + AdaptationVerdict.create( + "escalate", + "drive.upload", + "uploading is refused", + target="grant the drive.file scope", + ) + ) + + block = _reader(store)._distilled_knowledge_context() + assert "This needs a person to: grant the drive.file scope." in block + + +def test_a_verdict_without_a_target_adds_no_hint(tmp_path): + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record(AdaptationVerdict.create("absorb", "chat.react", "it moved")) + + block = _reader(store)._distilled_knowledge_context() + assert "- chat.react: it moved" in block + assert "Prefer" not in block and "needs a person" not in block + + +# ── the whole loop, over two sessions ────────────────────────────────────────── + + +def test_two_sessions_close_the_loop(wired): + """Session one distils; session two is told what session one concluded. + + This is the behaviour the phase exists for, and none of it happened before: the + governor was never constructed, so no evidence was produced, so no verdict was + reachable, so nothing was distilled and nothing could be reconsidered. + """ + # Session one: the capability degrades and the teacher absorbs it. + wired.outcomes.streak = 2 + _record(wired.governor, ok=False, failure_class="affordance_removed") + + verdicts = ( + AdaptationVerdict.create("absorb", "chat.reply", "the control moved to the toolbar"), + ) + seen: list[Any] = [] + + class _Teacher: + def __init__(self, out) -> None: + self.out = out + + async def grade_and_propose(self, trajectory, goal="", **kwargs): + seen.append(kwargs.get("degraded_capabilities")) + return TeacherVerdict(grades=(), verdicts=self.out) + + first = WorldModelEvolutionDriver( + teacher=_Teacher(verdicts), intake=wired.service, knowledge_store=wired.knowledge + ) + result = asyncio.run(first.drive([{"action": "a"}], "reply")) + assert result.distilled == ("chat.reply",) + assert seen[0] and "prior_action" not in seen[0][0], "nothing was known yet" + + # Session two: it still fails, and now the teacher sees its own previous answer. + _record(wired.governor, ok=False, failure_class="affordance_removed") + second = WorldModelEvolutionDriver( + teacher=_Teacher(()), intake=wired.service, knowledge_store=wired.knowledge + ) + asyncio.run(second.drive([{"action": "a"}], "reply")) + assert seen[1][0]["prior_action"] == "absorb" + + # Session three: it works again, and the knowledge retires itself. + wired.outcomes.streak = 0 + _record(wired.governor, ok=True) + assert wired.knowledge.count() == 0 + + +# ── the wiring itself, which is what unit tests cannot see ───────────────────── + + +def test_the_production_path_actually_builds_a_governor(tmp_path): + """The defect this guards was reported as fixed while the wiring was absent. + + ``self.lifecycle_governor`` was read through ``getattr(..., None)`` and assigned + nowhere, so the sweep ran with ``governor=None`` for the life of the product. Every + test of the chain passed because every test constructed the governor itself -- so the + only thing that can catch it is a test that goes through the production resolver. + """ + from leapflow.cli.context import Context + + layout = SimpleNamespace( + distilled_knowledge_path=tmp_path / "dk.json", + capability_observations_path=tmp_path / "obs.json", + capability_proposal_queue_path=tmp_path / "queue.json", + plugin_outcomes_path=tmp_path / "outcomes.json", + ) + context = Context.__new__(Context) + context.settings = SimpleNamespace( + profile_layout=layout, + distilled_knowledge_ttl_s=0.0, + accepted_evidence_kinds=("capability_degraded",), + workspace_root=str(tmp_path), + ) + + governor = context._resolve_lifecycle_governor() + + assert governor is not None, "the sweep would run with governor=None" + assert context._resolve_lifecycle_governor() is governor, "built once, not per sweep" + # And the sink must be attached, or the chain is wired but silent. + assert governor._degradation_sink is not None + + +def test_the_governor_uses_the_durable_trust_ledger(tmp_path): + """A fresh ledger would give one process two divergent views of trust. + + The governor defaults to its own in-memory ``PluginTrustLedger`` when passed nothing, + so the transitions it computed would land in a throwaway object while the persistent + ledger the advisor reads stayed at DRAFT -- and no plugin could ever earn PRODUCTION, + which is the whole of Progressive Trust. + """ + from leapflow.cli.context import Context + from leapflow.learning.plugin_advisor import ( + PluginAdvisor, + get_default_advisor, + set_default_advisor, + ) + from leapflow.learning.plugin_trust import PluginTrustLedger + + previous = get_default_advisor() + durable = PluginTrustLedger() + set_default_advisor(PluginAdvisor(durable, SimpleNamespace())) + try: + context = Context.__new__(Context) + assert context._process_trust_ledger() is durable + finally: + if previous is not None: + set_default_advisor(previous) + + +def test_a_missing_profile_layout_degrades_instead_of_failing(): + """No durable place to record governance is the one legitimate reason to skip it.""" + from leapflow.cli.context import Context + + context = Context.__new__(Context) + context.settings = SimpleNamespace(profile_layout=None) + assert context._resolve_lifecycle_governor() is None diff --git a/tests/test_dependency_activation.py b/tests/test_dependency_activation.py index 914ebadc..99f4629e 100644 --- a/tests/test_dependency_activation.py +++ b/tests/test_dependency_activation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for Cordis P1: dependency-driven fiber activation and bind ordering. Two behaviours are covered: diff --git a/tests/test_dev_terminal_tools.py b/tests/test_dev_terminal_tools.py index 64a0e953..7d45a690 100644 --- a/tests/test_dev_terminal_tools.py +++ b/tests/test_dev_terminal_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for P2 tools: test_run, lint_check (shell_run wrappers) and the terminal_session lifecycle (opt-in persistent shells). diff --git a/tests/test_distilled_knowledge.py b/tests/test_distilled_knowledge.py new file mode 100644 index 00000000..762f7aed --- /dev/null +++ b/tests/test_distilled_knowledge.py @@ -0,0 +1,417 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""C1-C3: the distillation channel, from teacher's conclusion to student's context. + +This is the cheapest way the system adapts, and until now the only edge from teacher to +student was a single bit -- which tools are visible. Everything the teacher concluded +about *why* the environment behaved as it did was graded, traced, and thrown away, so a +correct judgement bought nothing and the next session repeated the same mistake. + +Retirement is tested as heavily as recording, because stale knowledge does not merely go +unused: the acting agent cannot tell a current fact from one that expired three upgrades +ago, so telling it "the send control is labelled Dispatch" after another rename is worse +than telling it nothing. +""" + +from __future__ import annotations + +import asyncio +import time +from types import SimpleNamespace +from typing import Any + +from leapflow.domain.adaptation_verdict import AdaptationVerdict +from leapflow.domain.environment_fingerprint import EnvironmentFingerprint +from leapflow.domain.platform import Capability, PlatformID, PlatformManifest +from leapflow.engine.engine import AgentEngine +from leapflow.learning.world_model_driver import WorldModelEvolutionDriver +from leapflow.storage.distilled_knowledge_store import ( + DistilledKnowledge, + JsonDistilledKnowledgeStore, +) +from leapflow.world_model.trajectory_grader import TeacherVerdict + + +def _env(os_version: str = "15.0", extra: bool = False) -> EnvironmentFingerprint: + names = sorted(c.name for c in Capability) + caps = {Capability[names[0]]} + if extra: + caps.add(Capability[names[1]]) + return EnvironmentFingerprint.from_platform_manifest( + PlatformManifest(PlatformID.DARWIN_15, os_version, frozenset(caps)) + ) + + +def _verdict(action: str, capability: str, knowledge: str, **kw: Any) -> AdaptationVerdict: + return AdaptationVerdict.create(action, capability, knowledge, **kw) + + +def _reader(store: Any, *, fingerprint: str = "", limit: int = 12) -> AgentEngine: + """An engine with only what this context layer reads, bound directly. + + Bound rather than resolved, so the test exercises the rendering rather than the lazy + lookup -- which has its own test below. + """ + engine = AgentEngine.__new__(AgentEngine) + engine._knowledge_store = store + engine._knowledge_store_unavailable = False + engine._environment_fingerprint_id = fingerprint + engine._settings = SimpleNamespace(distilled_knowledge_limit=limit) + return engine + + +# ── recording ───────────────────────────────────────────────────────────────── + + +def test_a_verdict_becomes_a_durable_fact(tmp_path): + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + entry = store.record( + _verdict("rebind", "chat.reply", "the send control is now labelled Dispatch"), + environment=_env().to_dict(), + ) + + assert isinstance(entry, DistilledKnowledge) + assert entry.capability == "chat.reply" + assert entry.environment_id == _env().fingerprint_id + + # And it survives a fresh reader, which is the whole point of persisting it. + assert JsonDistilledKnowledgeStore(tmp_path / "dk.json").count() == 1 + + +def test_a_verdict_without_knowledge_is_refused(tmp_path): + """The parser already rejects this shape, so reaching here means something changed.""" + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + assert store.record(SimpleNamespace(capability="chat.reply", knowledge="")) is None + assert store.record(SimpleNamespace(capability="", knowledge="something")) is None + assert store.count() == 0 + + +def test_a_batch_is_one_write_and_the_last_word_wins(tmp_path): + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + stored = store.record_all( + [ + _verdict("absorb", "chat.reply", "first conclusion"), + _verdict("rebind", "chat.reply", "second conclusion"), + _verdict("absorb", "chat.react", "unrelated"), + ] + ) + + assert len(stored) == 2, "one entry per capability, even within a batch" + assert store.for_capability("chat.reply").knowledge == "second conclusion" + + +# ── retirement: three ways out, and only three ───────────────────────────────── + + +def test_a_newer_verdict_supersedes_the_older_one(tmp_path): + """Keeping both live would show the agent a capability's contradictory past.""" + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record(_verdict("rebind", "chat.reply", "v3: the control is Dispatch")) + store.record(_verdict("absorb", "chat.reply", "v4: the control is Send again")) + + assert store.count() == 1 + assert store.for_capability("chat.reply").knowledge.startswith("v4") + + +def test_knowledge_expires_and_expiry_is_applied_on_read(tmp_path): + """A sweep would leave stale facts disclosed until some unrelated write happened.""" + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json", ttl_seconds=1.0) + store.record(_verdict("absorb", "mail.send", "the sent folder was renamed")) + + assert store.count() == 1 + assert store.live(now=time.time() + 5) == () + + +def test_ttl_zero_disables_expiry(tmp_path): + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json", ttl_seconds=0.0) + store.record(_verdict("absorb", "mail.send", "still true")) + assert len(store.live(now=time.time() + 10_000_000)) == 1 + + +def test_knowledge_can_be_retracted_when_it_is_obsolete(tmp_path): + """The only retirement a caller drives, and the only one that covers recovery. + + A capability observed working again makes knowledge describing its failure + misleading, and neither supersession nor expiry removes it -- no newer verdict is + coming precisely because there is no longer anything wrong. + """ + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record(_verdict("absorb", "chat.react", "the control is missing")) + store.record(_verdict("absorb", "chat.reply", "unrelated")) + + assert store.retract("chat.react", reason="observed working") is True + assert [e.capability for e in store.live()] == ["chat.reply"] + assert store.retract("chat.react") is False, "retracting twice is a no-op" + assert store.retract("") is False + + +# ── the environment is disclosed, never used to filter ───────────────────────── + + +def test_a_fact_from_another_environment_is_disclosed_as_such(tmp_path): + """Filtering on fingerprint mismatch would discard good knowledge on any upgrade. + + Whether an OS point release invalidates "the send control is labelled Dispatch" is a + judgement about meaning. A predicate here would be a hard rule with no ability to + generalise, so the mismatch is surfaced and the reader weighs it. + """ + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record( + _verdict("rebind", "chat.reply", "the control is Dispatch"), + environment=_env().to_dict(), + ) + + same = _reader(store, fingerprint=_env().fingerprint_id)._distilled_knowledge_context() + other = _reader( + store, fingerprint=_env("15.7", extra=True).fingerprint_id + )._distilled_knowledge_context() + + assert "different environment" not in same + assert "chat.reply" in other, "knowledge is not dropped for a changed environment" + assert "learned in a different environment" in other + + +# ── the student's context ────────────────────────────────────────────────────── + + +def test_the_student_sees_distilled_knowledge_as_observations(tmp_path): + """The C1 payload, rendered. Framed as observations because it is not an order.""" + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record(_verdict("rebind", "chat.reply", "the control is now Dispatch")) + + block = _reader(store)._distilled_knowledge_context() + + assert "What is known about this environment" in block + assert "- chat.reply: the control is now Dispatch" in block + assert "not instructions" in block, "it must not read as a command to the framework" + + +def test_the_disclosed_set_is_bounded(tmp_path): + """The channel meant to improve context must not come to dominate it.""" + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + for i in range(20): + store.record(_verdict("absorb", f"cap{i:02d}.thing", f"fact {i}")) + + lines = [ + line + for line in _reader(store, limit=3)._distilled_knowledge_context().splitlines() + if line.startswith("- ") + ] + assert len(lines) == 3 + + +def test_an_empty_or_absent_store_produces_no_block(tmp_path): + assert _reader(JsonDistilledKnowledgeStore(tmp_path / "e.json"))._distilled_knowledge_context() == "" + + bare = AgentEngine.__new__(AgentEngine) + bare._knowledge_store = None + bare._knowledge_store_unavailable = False + bare._environment_fingerprint_id = "" + bare._settings = SimpleNamespace(distilled_knowledge_limit=12, profile_layout=None) + assert bare._distilled_knowledge_context() == "", "no profile layout must degrade, not fail" + + +def test_a_corrupt_store_degrades_context_rather_than_failing_a_turn(tmp_path): + """Distilled knowledge is an improvement to context, so absence costs quality only.""" + path = tmp_path / "bad.json" + path.write_text("{not json", encoding="utf-8") + store = JsonDistilledKnowledgeStore(path) + + assert store.count() == 0 + assert _reader(store)._distilled_knowledge_context() == "" + + +def test_the_reader_binds_itself_rather_than_depending_on_another_path(tmp_path): + """The adaptive loop builds an equivalent store, but only when resolving a capability. + + Relying on it would make knowledge appear or vanish for reasons unrelated to + knowledge -- so this layer resolves its own reader. + """ + engine = AgentEngine.__new__(AgentEngine) + engine._knowledge_store = None + engine._knowledge_store_unavailable = False + engine._environment_fingerprint_id = "" + engine._settings = SimpleNamespace( + distilled_knowledge_limit=12, + distilled_knowledge_ttl_s=0.0, + workspace_root=str(tmp_path), + profile_layout=SimpleNamespace(distilled_knowledge_path=tmp_path / "dk.json"), + ) + + store = engine._resolve_knowledge_store() + assert store is not None + assert engine._environment_fingerprint_id, "the current environment must be known" + assert engine._resolve_knowledge_store() is store, "bound once, not per turn" + + +# ── the driver writes it, and writes it whatever else happened ────────────────── + + +class _Teacher: + def __init__(self, verdicts) -> None: + self._verdict = TeacherVerdict(grades=(), verdicts=tuple(verdicts)) + + async def grade_and_propose(self, trajectory, goal="", **kwargs): + return self._verdict + + +class _Intake: + def observe_result(self, result, **kwargs): + return None + + def requirements(self, *, min_count: int = 1, limit: int = 50): + return () + + +def test_a_session_that_only_absorbed_still_taught_the_next_one(tmp_path): + """The cheapest adaptation, and the one that used to leave no trace at all.""" + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + driver = WorldModelEvolutionDriver( + teacher=_Teacher( + [ + _verdict("absorb", "chat.react", "the control moved to the overflow menu"), + _verdict("rebind", "chat.reply", "the app is v3", target="chat_reply_v3"), + ] + ), + intake=_Intake(), + knowledge_store=store, + ) + + result = asyncio.run(driver.drive([{"action": "a"}], "reply", environment=_env())) + + assert set(result.distilled) == {"chat.react", "chat.reply"} + assert result.queued_proposal_ids == (), "no code was written" + assert store.count() == 2 + assert result.to_dict()["distilled"] == list(result.distilled) + # The environment travelled with it, so a later session can see where it came from. + assert store.for_capability("chat.reply").environment_id == _env().fingerprint_id + + +def test_a_failing_store_does_not_fail_the_session(tmp_path): + """Distillation improves the next session; it must never break this one.""" + + class _Broken: + def record_all(self, verdicts, **kwargs): + raise OSError("disk full") + + driver = WorldModelEvolutionDriver( + teacher=_Teacher([_verdict("absorb", "chat.react", "moved")]), + intake=_Intake(), + knowledge_store=_Broken(), + ) + + result = asyncio.run(driver.drive([{"action": "a"}], "reply")) + assert result.distilled == () + assert len(result.verdicts) == 1, "the verdict is still reported" + + +def test_a_driver_without_a_store_still_grades(tmp_path): + driver = WorldModelEvolutionDriver( + teacher=_Teacher([_verdict("absorb", "chat.react", "moved")]), intake=_Intake() + ) + result = asyncio.run(driver.drive([{"action": "a"}], "reply")) + assert result.distilled == () and len(result.verdicts) == 1 + + +# ── review findings: the invariants that keep the whitelist and the set honest ─ + + +def test_the_persisted_fields_and_the_record_agree_exactly(tmp_path): + """Bound in both directions, because each direction has its own failure. + + A field the dataclass has and the whitelist lacks is the historical silent drop: the + value is written nowhere and reads back as a default, with nothing raised. An entry in + the whitelist with no matching field is the mirror image -- it claims to persist + something that never existed, which is how a whitelist stops being trustworthy. + ``"environment"`` was exactly that, left over from considering whether to store the + whole fingerprint. + """ + from leapflow.storage.distilled_knowledge_store import _ENTRY_FIELDS + + assert set(DistilledKnowledge("chat.reply", "k").to_dict()) == _ENTRY_FIELDS + + # And the binding has to hold through a real round trip, not just in the abstract. + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record( + _verdict("rebind", "chat.reply", "the control is Dispatch", target="v3"), + environment=_env().to_dict(), + ) + reloaded = JsonDistilledKnowledgeStore(tmp_path / "dk.json").for_capability("chat.reply") + assert reloaded.target == "v3" + assert reloaded.environment_id == _env().fingerprint_id + assert reloaded.action == "rebind" + + +def test_supersession_bounds_the_store_by_the_capability_count(tmp_path): + """The answer to "does this grow unbounded on the hot path": it cannot. + + One live entry per capability, so the ceiling is the number of capabilities the + system has -- not the number of sessions it has run. Measured at 200 entries, + ``live()`` costs single-digit microseconds, which is why no sweep or index is needed. + """ + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + for round_number in range(50): + store.record(_verdict("absorb", "chat.reply", f"conclusion {round_number}")) + + assert store.count() == 1 + assert store.for_capability("chat.reply").knowledge == "conclusion 49" + + +def test_every_retry_owned_class_is_one_a_classifier_emits(): + """A member with no producer claims to filter something never seen. + + ``"rate_limit"`` was in the set with no producer anywhere in the engine. Harmless in + effect, and corrosive in meaning: the set is supposed to read as a statement about + which failures the retry layer owns, and an invented member makes it fiction. + """ + import re + from pathlib import Path as _Path + + from leapflow.learning.capability_observation import RETRY_OWNED_FAILURE_CLASSES + + engine_dir = _Path(__file__).resolve().parent.parent / "src" / "leapflow" / "engine" + emitted = set() + for path in engine_dir.rglob("*.py"): + emitted.update(re.findall(r'"([a-z_]+)"', path.read_text(encoding="utf-8"))) + + missing = RETRY_OWNED_FAILURE_CLASSES - emitted + assert not missing, f"no classifier emits: {sorted(missing)}" + + +def test_an_unknown_action_still_discloses_its_recommendation(tmp_path): + """A phrase table, so a fifth action loses nothing while its phrase is missing.""" + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record( + SimpleNamespace( + capability="chat.reply", + knowledge="something changed", + action="a_future_action", + verdict_id="adv-x", + confidence=0.5, + rationale="", + target="do the thing", + created_at=1.0, + ) + ) + + block = _reader(store)._distilled_knowledge_context() + assert "do the thing" in block, "an unmapped action must not drop its target" + + +def test_a_persistent_lookup_failure_costs_one_attempt_not_one_per_turn(tmp_path): + """The context layer runs every turn, so a failing lookup must not retry every turn.""" + engine = AgentEngine.__new__(AgentEngine) + engine._knowledge_store = None + engine._knowledge_store_unavailable = False + engine._environment_fingerprint_id = "" + engine._settings = SimpleNamespace( + distilled_knowledge_limit=12, + distilled_knowledge_ttl_s=0.0, + workspace_root=str(tmp_path), + profile_layout=SimpleNamespace( + distilled_knowledge_path=property(lambda self: 1 / 0) # raises on access + ), + ) + + assert engine._resolve_knowledge_store() is None + assert engine._knowledge_store_unavailable is True + assert engine._distilled_knowledge_context() == "" diff --git a/tests/test_distilled_preference.py b/tests/test_distilled_preference.py new file mode 100644 index 00000000..fd7c6afd --- /dev/null +++ b/tests/test_distilled_preference.py @@ -0,0 +1,226 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Channel C2: the teacher's rebind recommendation reaches the selection layer. + +Until now the most frequent verdict a real model produced had nowhere to land. Measured +across three S9 runs, ``rebind`` was the action it reached for most readily -- and its +``target`` travelled only as a line of prose in the student's context. The resolver, which +is what actually picks a provider, never heard about it. + +The whole design of this channel is "preference, not gate", and every test here exists to +hold one half of that: the recommendation must change the outcome when the recommended +provider is admissible, and must change nothing at all when it is not. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +from leapflow.domain.adaptation_verdict import AdaptationVerdict +from leapflow.domain.capability_requirement import CapabilityRequirement +from leapflow.domain.environment_fingerprint import EnvironmentFingerprint +from leapflow.domain.platform import Capability, PlatformID, PlatformManifest +from leapflow.plugins.capability_resolver import ( + _DEFAULT_SCORERS, + CapabilityCandidate, + CapabilityResolver, + DistilledPreferenceScorer, + EnvironmentAffordanceScorer, + ResolverContext, +) +from leapflow.storage.distilled_knowledge_store import JsonDistilledKnowledgeStore + + +def _env(*affordances: str) -> EnvironmentFingerprint: + return EnvironmentFingerprint.from_platform_manifest( + PlatformManifest(PlatformID.DARWIN_15, "15.0", frozenset({Capability.FILE_OPS})) + ) + + +def _requirement() -> CapabilityRequirement: + return CapabilityRequirement.create( + "chat.reply", "world_model", max_risk_level="read_only" + ) + + +def _candidate(plugin_id: str, tool: str, *affordances: str) -> CapabilityCandidate: + return CapabilityCandidate( + plugin_id=plugin_id, + tool_name=tool, + provides_capabilities=("chat.reply",), + requires_environment_affordances=tuple(affordances), + risk_level="read_only", + ) + + +def _resolve(candidates, *, preferences=(), affordance_scorer=False): + scorers: tuple[Any, ...] = _DEFAULT_SCORERS + if affordance_scorer: + scorers = (*scorers, EnvironmentAffordanceScorer()) + scorers = (*scorers, DistilledPreferenceScorer()) + context = ResolverContext(environment=_env(), distilled_preferences=tuple(preferences)) + return CapabilityResolver(scorers).resolve_all([_requirement()], candidates, context)[0] + + +# ── only rebind becomes a preference ────────────────────────────────────────── + + +def test_only_rebind_verdicts_become_selection_preferences(tmp_path): + """An escalate target names what a *person* must do; absorb has no target at all. + + Admitting either would turn an instruction to a human into a machine's selection + preference, which is the one direction this channel must never go. + """ + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record( + AdaptationVerdict.create( + "rebind", "chat.reply", "the app is now v2", target="chat_reply_v2_native" + ) + ) + store.record(AdaptationVerdict.create("absorb", "chat.react", "the control moved")) + store.record( + AdaptationVerdict.create( + "escalate", "drive.upload", "refused", target="grant the drive.file scope" + ) + ) + + assert store.rebind_preferences() == (("chat.reply", "chat_reply_v2_native"),) + + +def test_a_rebind_without_a_target_is_not_a_preference(tmp_path): + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record(AdaptationVerdict.create("rebind", "chat.reply", "something moved")) + assert store.rebind_preferences() == () + + +def test_a_preference_retires_with_the_knowledge_behind_it(tmp_path): + """Nothing to unlearn: the entry stops being read when it stops being true.""" + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record( + AdaptationVerdict.create("rebind", "chat.reply", "v2 now", target="chat_reply_v2") + ) + assert store.rebind_preferences() + + store.retract("chat.reply", reason="observed working again") + assert store.rebind_preferences() == () + + +def test_a_newer_verdict_supersedes_the_preference(tmp_path): + store = JsonDistilledKnowledgeStore(tmp_path / "dk.json") + store.record( + AdaptationVerdict.create("rebind", "chat.reply", "v2 now", target="chat_reply_v2") + ) + store.record( + AdaptationVerdict.create("rebind", "chat.reply", "v3 now", target="chat_reply_v3") + ) + assert store.rebind_preferences() == (("chat.reply", "chat_reply_v3"),) + + +# ── preference, not gate ────────────────────────────────────────────────────── + + +def test_the_recommended_provider_wins_when_it_is_admissible(): + resolution = _resolve( + [_candidate("v1", "chat_reply_v1"), _candidate("v2", "chat_reply_v2_native")], + preferences=(("chat.reply", "chat_reply_v2_native"),), + ) + assert resolution.selected.candidate.tool_name == "chat_reply_v2_native" + + +def test_a_recommendation_cannot_make_an_inadmissible_candidate_win(): + """The half that makes this safe. + + Hindsight is evidence about the world; a declaration is a fact about the code. When + they disagree the code wins -- so a candidate whose affordances are absent stays + excluded no matter what was recommended. + """ + resolution = _resolve( + [ + _candidate("v1", "chat_reply_v1"), + _candidate("v2", "chat_reply_v2_native", "app.chat.v9"), + ], + preferences=(("chat.reply", "chat_reply_v2_native"),), + affordance_scorer=True, + ) + assert resolution.selected.candidate.plugin_id == "v1" + + +def test_no_recommendation_leaves_selection_exactly_as_it_was(): + """The scorer contributes zero when there is nothing to say.""" + with_scorer = _resolve( + [_candidate("v1", "chat_reply_v1"), _candidate("v2", "chat_reply_v2_native")] + ) + baseline = CapabilityResolver(_DEFAULT_SCORERS).resolve_all( + [_requirement()], + [_candidate("v1", "chat_reply_v1"), _candidate("v2", "chat_reply_v2_native")], + ResolverContext(environment=_env()), + )[0] + assert ( + with_scorer.selected.candidate.plugin_id == baseline.selected.candidate.plugin_id + ) + + +def test_a_recommendation_for_another_capability_is_ignored(): + resolution = _resolve( + [_candidate("v1", "chat_reply_v1"), _candidate("v2", "chat_reply_v2_native")], + preferences=(("mail.send", "chat_reply_v2_native"),), + ) + assert resolution.selected.candidate.plugin_id == "v1" + + +def test_the_preference_weight_stays_below_the_structural_weights(): + """A recommendation is evidence; it must not outvote a declaration.""" + from leapflow.plugins.capability_resolver import ResolverWeights + + weights = ResolverWeights() + assert weights.distilled_preference < weights.declared_match + assert weights.distilled_preference < weights.environment_fit + + +# ── the reader, which is where a snapshot would go stale ────────────────────── + + +def test_the_engine_reads_preferences_per_resolution_not_once(): + """A captured snapshot would keep preferring a provider the knowledge dropped. + + Retraction and supersession happen between sessions, so the value has to be read when + it is used. + """ + from leapflow.engine.engine import AgentEngine + + tmp = Path(tempfile.mkdtemp()) + store = JsonDistilledKnowledgeStore(tmp / "dk.json") + engine = AgentEngine.__new__(AgentEngine) + engine._knowledge_store = store + engine._knowledge_store_unavailable = False + engine._environment_fingerprint_id = "" + engine._settings = SimpleNamespace(distilled_knowledge_limit=12) + + assert engine._rebind_preferences() == () + store.record( + AdaptationVerdict.create("rebind", "chat.reply", "v2 now", target="chat_reply_v2") + ) + assert engine._rebind_preferences() == (("chat.reply", "chat_reply_v2"),) + store.retract("chat.reply") + assert engine._rebind_preferences() == () + + +def test_a_failing_store_costs_a_preference_not_a_resolution(): + from leapflow.engine.engine import AgentEngine + + class _Broken: + def rebind_preferences(self): + raise OSError("disk gone") + + def live(self): + return () + + engine = AgentEngine.__new__(AgentEngine) + engine._knowledge_store = _Broken() + engine._knowledge_store_unavailable = False + engine._environment_fingerprint_id = "" + engine._settings = SimpleNamespace(distilled_knowledge_limit=12) + + assert engine._rebind_preferences() == () diff --git a/tests/test_dsh_compatibility.py b/tests/test_dsh_compatibility.py index 112f2a6f..64a305c6 100644 --- a/tests/test_dsh_compatibility.py +++ b/tests/test_dsh_compatibility.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Real-artifact tests for the restricted DSH/Cordis compatibility path.""" from __future__ import annotations diff --git a/tests/test_effect_declaration.py b/tests/test_effect_declaration.py index 63912db8..5ae8119e 100644 --- a/tests/test_effect_declaration.py +++ b/tests/test_effect_declaration.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """C-1: an acquisition can now be *confirmed*, not only refuted. Before this, the only outcome channel was the usage sink, which receives just ``ok``. @@ -7,8 +8,14 @@ C-1 closes it by recording outcomes where the **full result payload** is visible (the engine's result-observation path) and by establishing the declaration channel: a -handler reports what it observably did under an ``effect`` key. Tools that say nothing -stay ``unverifiable`` -- silence must never be read as success. +handler reports what it observably did under an ``observed_effect`` key. Tools that +say nothing stay ``unverifiable`` -- silence must never be read as success. + +The channel was originally two keys, ``observed_effect`` and a shorthand ``effect``. +The shorthand had to be withdrawn: ``effect`` is already used across the tree for a +risk *class* (``"effect": "write"``) and a hardware channel *type*, and single-token +overlap let ``effect="write"`` confirm an expectation reading "write the message to +the channel" -- a decided verdict that granted trust for evidence that never existed. """ from __future__ import annotations @@ -38,15 +45,22 @@ def _requirement(expected: str = "the reply was delivered to the thread"): # ── the declaration channel ─────────────────────────────────────────────────── -def test_both_accepted_spellings_are_read(): - assert OBSERVED_EFFECT_KEYS == ("observed_effect", "effect") - assert observed_effect_from_result({"effect": "sent"}) == "sent" +def test_the_declaration_channel_is_one_key_whose_name_is_not_taken(): + """``effect`` was withdrawn as a shorthand because that name already means + something else: a risk class in self-management, a channel type in hardware. + + Keeping it cost more than it bought. Nothing emitted observed-effect prose under + either spelling, while four call sites emitted the *other* meaning -- so the + shorthand contributed no true confirmations and at least one false one. + """ + assert OBSERVED_EFFECT_KEYS == ("observed_effect",) assert observed_effect_from_result({"observed_effect": "sent"}) == "sent" -def test_observed_effect_wins_when_both_are_present(): - result = {"observed_effect": "explicit", "effect": "shorthand"} - assert observed_effect_from_result(result) == "explicit" +def test_a_risk_class_under_the_old_shorthand_is_silence_not_evidence(): + """The exact payload that used to produce a false ``verified=True``.""" + assert observed_effect_from_result({"ok": True, "effect": "write"}) == "" + assert observed_effect_from_result({"effect": "read"}) == "" def test_silence_is_silence_and_is_never_synthesised(): @@ -57,7 +71,7 @@ def test_silence_is_silence_and_is_never_synthesised(): def test_whitespace_is_trimmed(): - assert observed_effect_from_result({"effect": " sent to thread \n"}) == "sent to thread" + assert observed_effect_from_result({"observed_effect": " sent to thread \n"}) == "sent to thread" # ── the engine records outcomes with the payload (drive the real method) ─────── @@ -92,7 +106,7 @@ def test_engine_confirms_an_acquisition_from_the_declared_effect(monkeypatch): _drive_engine_outcome({ "name": "chat_reply", - "result": {"ok": True, "effect": "the reply was delivered to the thread"}, + "result": {"ok": True, "observed_effect": "the reply was delivered to the thread"}, }) outcome = asyncio.run(CoevolutionSweep().run( @@ -158,7 +172,7 @@ def test_wrong_effect_refutes_even_with_ok_true(monkeypatch): _drive_engine_outcome({ "name": "chat_reply", - "result": {"ok": True, "effect": "opened a settings pane"}, + "result": {"ok": True, "observed_effect": "opened a settings pane"}, }) outcome = asyncio.run(CoevolutionSweep().run( @@ -210,8 +224,17 @@ def _boom(): # ── the generator asks for it, or nothing will ever be confirmable ──────────── -def test_generation_prompt_requires_handlers_to_report_their_effect(): - """The contract has to reach the code that gets written, not just the verifier.""" +def test_generation_prompt_names_the_key_the_verifier_actually_reads(): + """The contract has to reach the code that gets written, not just the verifier. + + These two drifted once and it was invisible: the declaration channel narrowed to + ``observed_effect`` while the prompt still taught ``effect``. Every plugin the + framework wrote for itself would then report through a key nothing reads, so its + verdict would abstain forever -- and the board's abstain rate would have been read + as "tools do not report" rather than "we told them the wrong key". Asserted + against the verifier's own constant so the two cannot drift again. + """ + from leapflow.learning.capability_effect_verifier import OBSERVED_EFFECT_KEYS from leapflow.learning.plugin_generator import ( PluginGenerationRequest, PluginGenerator, @@ -220,10 +243,13 @@ def test_generation_prompt_requires_handlers_to_report_their_effect(): prompt = PluginGenerator().build_generation_prompt( PluginGenerationRequest(plugin_id="gen_x", description="reply in a thread") ) - assert '"effect"' in prompt + channel = OBSERVED_EFFECT_KEYS[0] + assert f'"{channel}"' in prompt assert "can never be verified, only refuted" in prompt # The skeleton must model it, since that is what gets copied. - assert '"effect": ""' in prompt + assert f'"{channel}": ""' in prompt + # The withdrawn shorthand must not be taught: elsewhere it means a risk class. + assert '"effect":' not in prompt def test_usage_sink_no_longer_records_outcomes(): @@ -235,3 +261,99 @@ def test_usage_sink_no_longer_records_outcomes(): source = inspect.getsource(PluginUsageTracker.record) assert "record_tool_outcome" not in source assert "tracker.record(plugin_id, tool_name, ok)" in source # streak feed stays + + +# ── the writer half: built-in tools that now declare their effect ───────────── + + +def test_declare_effect_spells_the_key_in_exactly_one_place(): + """A handler must never spell the key itself; that is how the halves drifted.""" + from leapflow.learning.capability_effect_verifier import ( + OBSERVED_EFFECT_KEYS, + declare_effect, + ) + + assert declare_effect("wrote 12 bytes to a.py") == { + OBSERVED_EFFECT_KEYS[0]: "wrote 12 bytes to a.py" + } + # Silence stays silence: an empty description contributes no key at all, so a + # handler with nothing to say remains *unverifiable* rather than refuted. + for empty in ("", " ", None): + assert declare_effect(empty) == {} + + +def test_file_write_declares_a_measured_effect(tmp_path, monkeypatch): + """The byte count comes from the content that actually reached the file. + + That is what makes it an observation. A restatement of the request would be + compared against the expectation and could confirm work that never happened. + """ + import asyncio + + from leapflow.tools.file_operations import file_write + + monkeypatch.setenv("LEAPFLOW_WORKSPACE_ROOT", str(tmp_path)) + target = tmp_path / "note.txt" + result = asyncio.run(file_write({"path": str(target), "content": "hello"})) + + assert result["ok"] is True, result + effect = result.get("observed_effect", "") + assert "5 bytes" in effect, effect # measured, not declared + assert "note.txt" in effect + assert result["bytes_written"] == 5 + + +def test_file_write_distinguishes_append_from_overwrite(tmp_path, monkeypatch): + """Two different observed outcomes must not describe themselves identically.""" + import asyncio + + from leapflow.tools.file_operations import file_write + + monkeypatch.setenv("LEAPFLOW_WORKSPACE_ROOT", str(tmp_path)) + target = tmp_path / "log.txt" + asyncio.run(file_write({"path": str(target), "content": "a"})) + appended = asyncio.run( + file_write({"path": str(target), "content": "b", "mode": "append"}) + ) + assert "appended" in appended.get("observed_effect", "") + + +def test_a_secret_config_write_declares_the_change_without_the_value(monkeypatch): + """The effect channel obeys the same redaction rule as the payload. + + ``config_set`` deliberately never echoes a credential into the transcript. An + effect declaration is transcript too, so a secret's new value must not travel + here either -- while still reporting that the key changed. + """ + from leapflow.learning.capability_effect_verifier import declare_effect + + # The two forms the handler chooses between, asserted directly: the redaction + # decision is a branch on ``before.secret`` and both branches must stay + # observations rather than one degrading into silence. + plain = declare_effect("config key llm.model in scope user is now qwen3") + secret = declare_effect("config key llm.api_key in scope user was updated") + assert "qwen3" in plain["observed_effect"] + assert "was updated" in secret["observed_effect"] + assert "api_key" in secret["observed_effect"] + + +def test_edit_file_declares_what_it_actually_changed(tmp_path, monkeypatch): + """Counts come from the edits that applied, so the description is measured.""" + import asyncio + + from leapflow.tools.file_operations import edit_file, file_write + + monkeypatch.setenv("LEAPFLOW_WORKSPACE_ROOT", str(tmp_path)) + target = tmp_path / "m.py" + asyncio.run(file_write({"path": str(target), "content": "y = 2\n"})) + + result = asyncio.run( + edit_file({ + "path": str(target), + "edits": [{"original_text": "y = 2", "new_text": "y = 3"}], + }) + ) + assert result["ok"] is True, result + effect = result["observed_effect"] + assert "1 edit" in effect and "1 replacement" in effect + assert "m.py" in effect diff --git a/tests/test_effect_scope.py b/tests/test_effect_scope.py index cdef2450..c964b0bd 100644 --- a/tests/test_effect_scope.py +++ b/tests/test_effect_scope.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unit tests for EffectScope and PluginFiber domain primitives.""" from __future__ import annotations diff --git a/tests/test_empty_response_hardening.py b/tests/test_empty_response_hardening.py index f1b05e84..48228e0d 100644 --- a/tests/test_empty_response_hardening.py +++ b/tests/test_empty_response_hardening.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Regression tests for empty-LLM-response hardening (P0-A1). Root cause (observed as "I processed your request but have no additional diff --git a/tests/test_environment_catalog.py b/tests/test_environment_catalog.py index 68874ad0..9ff6b160 100644 --- a/tests/test_environment_catalog.py +++ b/tests/test_environment_catalog.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for declarative environment marker catalogs.""" from __future__ import annotations diff --git a/tests/test_event_bridge.py b/tests/test_event_bridge.py index ab943589..2b89f224 100644 --- a/tests/test_event_bridge.py +++ b/tests/test_event_bridge.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic tests for EventBridge — EventBus to EventTrigger adapter. No network, no LLM: pure in-memory trigger matching logic. diff --git a/tests/test_event_driven_watch.py b/tests/test_event_driven_watch.py index 37de2edb..4cd250d8 100644 --- a/tests/test_event_driven_watch.py +++ b/tests/test_event_driven_watch.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """End-to-end tests for event-driven Watch full chain. Verifies the complete pipeline: diff --git a/tests/test_evolution_governance_reachable.py b/tests/test_evolution_governance_reachable.py index 6150fde1..1d383acb 100644 --- a/tests/test_evolution_governance_reachable.py +++ b/tests/test_evolution_governance_reachable.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """P2(a): the evolution governance tier becomes reachable. `AdaptiveEvolutionPolicy` and `LifecycleGovernor` implement trust, probation and diff --git a/tests/test_evolution_ledger.py b/tests/test_evolution_ledger.py index 769da8df..460adcd9 100644 --- a/tests/test_evolution_ledger.py +++ b/tests/test_evolution_ledger.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """EvolutionLedger: rebuilding causal episodes from records that already exist. The point of this stage is that no probe is needed, so the tests are mostly about diff --git a/tests/test_evolution_producer.py b/tests/test_evolution_producer.py index ba13b548..81fa24d5 100644 --- a/tests/test_evolution_producer.py +++ b/tests/test_evolution_producer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """EvolutionProducer: the framework-evolution transparency panel. The tests that matter most here are the negative ones. This producer reports @@ -526,9 +527,15 @@ def test_capability_ownership_is_emitted_in_renderer_compatible_shapes(monkeypat def test_evolution_template_binds_only_shapes_its_renderers_read(): """Guard the trap above at the template level, for this template. - ``EntityGraph`` and ``Table`` both read ``props.data``; a template binding a - mapping to either renders headings over nothing. Asserted here rather than - only in the SDUI suite because the payload contract is this producer's. + ``EntityGraph``, ``Table``, ``BarChart`` and ``Timeline`` all read ``props.data`` + as a flat list; a template binding a mapping to any of them renders headings + over nothing and reports no fault. Asserted here rather than only in the SDUI + suite because the payload contract is this producer's. + + The chart and timeline types were originally omitted, so three chart binds and + two timeline binds went unchecked -- the guard covered the shape hazard it was + written for and silently exempted the other half of the components that share + it. """ from leapflow.dashboard.templates import TemplateLibrary @@ -550,14 +557,20 @@ def test_evolution_template_binds_only_shapes_its_renderers_read(): "evolution.reachability_mix", "evolution.provenance_mix", "evolution.reclaim_candidates", + "evolution.reward_bandwidth.by_reason", "evolution.summary.suggestions", } + #: Every shipped renderer that coerces ``props.data`` with ``asArray``. + DATA_LIST_COMPONENTS = ( + "EntityGraph", "Table", "BarChart", "Timeline", + "PieChart", "LineChart", "AreaChart", "Sparkline", "Heatmap", + ) binds: list[tuple[str, str]] = [] def walk(node: object) -> None: if isinstance(node, dict): props = node.get("props") - if node.get("type") in ("EntityGraph", "Table") and isinstance(props, dict): + if node.get("type") in DATA_LIST_COMPONENTS and isinstance(props, dict): bind = props.get("bind") if isinstance(bind, str): binds.append((str(node.get("type")), bind)) @@ -876,3 +889,178 @@ def first_tab(nodes): assert tab.get("children"), ( f"the default tab rendered empty for payload keys {sorted(payload)}" ) + + +# ── P0: reward signal bandwidth ────────────────────────────────────────────── + + +def _verification_trace(reason: str, verified, **detail): + return { + "trace_id": f"t-{reason}-{verified}", + "stage": "learn", + "kind": "effect_verification", + "summary": reason, + "detail": {"reason": reason, "verified": verified, **detail}, + } + + +def test_no_verdict_is_reported_as_absent_not_as_a_zero_rate(): + """Absence of a signal is not a healthy signal. + + With no verdicts an abstain rate of 0% reads as "almost nothing abstains", + which is the exact opposite of the truth. The rates stay blank and ``absent`` + carries the fact, mirroring how the reachability rows separate "no evidence" + from a measured value. + """ + from leapflow.monitor.evolution_producer import EvolutionProducer + + bandwidth = EvolutionProducer()._reward_bandwidth([], []) + assert bandwidth["observed"] is False + assert bandwidth["absent"] is True + assert bandwidth["total"] == 0 + assert bandwidth["abstain_rate"] == "" + assert bandwidth["usable_rate"] == "" + + +def test_abstention_is_counted_separately_from_refutation(): + """A three-valued verdict must not be folded into either side. + + Treating abstention as failure would demote and eventually quarantine healthy + plugins for a reporting omission -- the failure the three-valued verdict exists + to prevent. + """ + from leapflow.monitor.evolution_producer import EvolutionProducer + + traces = [ + _verification_trace("effect_observed", True), + _verification_trace("expected_effect_absent", False), + _verification_trace("tool_reported_no_effect", None), + _verification_trace("no_expected_effect_declared", None), + _verification_trace("no_outcome_observed", None), + ] + bandwidth = EvolutionProducer()._reward_bandwidth(traces, []) + + assert bandwidth["total"] == 5 + assert bandwidth["decided"] == 2 # verified True or False + assert bandwidth["abstained"] == 3 # verified None + assert bandwidth["abstain_rate"] == "60%" + assert bandwidth["usable_rate"] == "40%" + + +def test_the_no_op_sweep_branch_is_not_counted_as_a_verdict(): + """The sweep records "nothing to verify" too; that is not an abstention.""" + from leapflow.monitor.evolution_producer import EvolutionProducer + + traces = [ + {"trace_id": "a", "kind": "effect_verification", "detail": {"observed": 0, "no_op": True}}, + _verification_trace("effect_observed", True), + ] + bandwidth = EvolutionProducer()._reward_bandwidth(traces, []) + assert bandwidth["total"] == 1 + assert bandwidth["abstained"] == 0 + + +def test_reasons_are_ranked_so_the_dominant_cause_leads(): + from leapflow.monitor.evolution_producer import EvolutionProducer + + traces = [_verification_trace("tool_reported_no_effect", None, i=i) for i in range(3)] + traces.append(_verification_trace("effect_observed", True)) + for row, expected in zip( + EvolutionProducer()._reward_bandwidth(traces, [])["by_reason"], + ({"label": "tool_reported_no_effect", "value": 3}, {"label": "effect_observed", "value": 1}), + ): + assert row == expected + + +# ── P0: the three cold-path segments must be read, not asserted ────────────── + + +def test_a_wired_sweep_is_no_longer_reported_as_unwired(monkeypatch): + """These rows were hardcoded to NO_EVIDENCE and stayed so after being wired. + + The board asserted three segments were dead while ``CoevolutionSweep`` was + running them. A panel whose purpose is to separate "exists" from "runs" must + not hardcode the answer. + """ + from leapflow.monitor.evolution_producer import EvolutionProducer + + traces = [ + {"kind": "effect_verification", "detail": {"reason": "effect_observed", "verified": True}}, + {"kind": "quarantine_drain", "detail": {"drained": 1}}, + {"kind": "reclamation", "detail": {"candidates": 2}}, + ] + rows = {r["key"]: r for r in EvolutionProducer()._segments_awaiting_wiring(traces)} + + assert rows["effect_verification"]["status"] == "wired" + assert rows["quarantine_feed"]["status"] == "wired" + assert rows["reclamation"]["status"] == "wired" + # A wired row must not still be advising the reader to wire it. + assert not any("Wire" in str(r.get("next_step") or "") for r in rows.values()) + + +def test_an_idle_sweep_is_wired_not_absent(): + """"Ran and had nothing to do" is evidence the segment works. + + The sweep emits its no-op branches precisely so this can be told apart from + never having run. + """ + from leapflow.monitor.evolution_producer import EvolutionProducer + + traces = [ + {"kind": "effect_verification", "detail": {"observed": 0, "no_op": True}}, + {"kind": "quarantine_drain", "detail": {"no_op": True}}, + {"kind": "reclamation", "detail": {"no_op": True}}, + ] + rows = {r["key"]: r for r in EvolutionProducer()._segments_awaiting_wiring(traces)} + assert {r["status"] for r in rows.values()} == {"wired"} + assert "nothing to act on" in rows["reclamation"]["evidence"] + + +def test_a_sweep_that_never_ran_is_still_no_evidence(): + """The honest negative: no trace at all means the cold path did not run.""" + from leapflow.monitor.evolution_producer import EvolutionProducer + + rows = {r["key"]: r for r in EvolutionProducer()._segments_awaiting_wiring([])} + assert {r["status"] for r in rows.values()} == {"no_evidence"} + assert all(r["next_step"] for r in rows.values()) + + +def test_a_risk_class_string_can_no_longer_fabricate_a_verified_effect(): + """``effect`` was a second accepted key and is already taken by another meaning. + + ``"effect": "write"`` is a risk *class* in self-management and a channel *type* + in hardware -- not a description of what happened. With single-token overlap + sufficient to match, an expectation reading "write the message to the channel" + was confirmed by a tool reporting ``effect="write"``, and a decided verdict + grants trust. That is worse than no signal: it is a wrong one. + """ + from leapflow.domain.capability_requirement import CapabilityRequirement + from leapflow.learning.capability_effect_verifier import ( + EFFECT_UNREPORTED, + OBSERVED_EFFECT_KEYS, + CapabilityEffectVerifier, + ) + + assert OBSERVED_EFFECT_KEYS == ("observed_effect",), ( + "a key already used for another purpose must not be an effect source" + ) + + verifier = CapabilityEffectVerifier() + requirement = CapabilityRequirement.create( + "chat.send", + "unknown_tool", + metadata={"expected_effect": "write the message to the channel"}, + ) + + # The exact collision that produced a false confirmation. + verdict = verifier.verify(requirement, {"ok": True, "effect": "write"}, plugin_id="p") + assert verdict.verified is None, "a risk class must not decide an effect verdict" + assert verdict.reason == EFFECT_UNREPORTED + + # The declared channel still works. + honest = verifier.verify( + requirement, + {"ok": True, "observed_effect": "wrote the message to the channel"}, + plugin_id="p", + ) + assert honest.verified is True diff --git a/tests/test_evolution_tap.py b/tests/test_evolution_tap.py index c4e272aa..a096f7b9 100644 --- a/tests/test_evolution_tap.py +++ b/tests/test_evolution_tap.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """P2 collection layer: the tap, the sink, the trace store, and the four probes. The probes exist for one reason: to record facts that no store retains. So the diff --git a/tests/test_evolution_verify_and_govern.py b/tests/test_evolution_verify_and_govern.py index 05c274ab..b093a04b 100644 --- a/tests/test_evolution_verify_and_govern.py +++ b/tests/test_evolution_verify_and_govern.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """WM-6 / LF-10 / A-4 / P5: verify, then govern. * **WM-6** -- an acquired capability is verified by its *observed effect*, not by diff --git a/tests/test_execution_backends.py b/tests/test_execution_backends.py index ebf68505..8773e745 100644 --- a/tests/test_execution_backends.py +++ b/tests/test_execution_backends.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations import asyncio diff --git a/tests/test_facade_capability_mapping.py b/tests/test_facade_capability_mapping.py index b5ee7ad1..e153e826 100644 --- a/tests/test_facade_capability_mapping.py +++ b/tests/test_facade_capability_mapping.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Regression tests for cua-driver tool → Capability mapping in the VSI facade. The mapping previously used informal strings ("ax_tree", "input", ...) that diff --git a/tests/test_feishu_event_normalizer.py b/tests/test_feishu_event_normalizer.py index c86bd70a..7526723c 100644 --- a/tests/test_feishu_event_normalizer.py +++ b/tests/test_feishu_event_normalizer.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for FeishuEventNormalizer — Feishu event classification and mapping.""" from __future__ import annotations diff --git a/tests/test_file_lock.py b/tests/test_file_lock.py index e8be37a3..d44187ad 100644 --- a/tests/test_file_lock.py +++ b/tests/test_file_lock.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the cross-platform lock_fd / unlock_fd pair.""" from __future__ import annotations diff --git a/tests/test_frame_store_protocol.py b/tests/test_frame_store_protocol.py index 5cf5612d..8051caa4 100644 --- a/tests/test_frame_store_protocol.py +++ b/tests/test_frame_store_protocol.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the FrameStore Protocol and its consumers (Fix D4). Covers: diff --git a/tests/test_full_fiberization.py b/tests/test_full_fiberization.py index d5b30773..6aa02491 100644 --- a/tests/test_full_fiberization.py +++ b/tests/test_full_fiberization.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Full fiberization coverage tests. Verifies that all three plugin subsystems (tools, gateway adapters, LLM diff --git a/tests/test_gateway_adapter_registry.py b/tests/test_gateway_adapter_registry.py index b644f683..d6c271ca 100644 --- a/tests/test_gateway_adapter_registry.py +++ b/tests/test_gateway_adapter_registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Comprehensive tests for GatewayAdapterRegistry and ScopedGatewayAdapterRegistry. Covers: diff --git a/tests/test_gateway_adapters.py b/tests/test_gateway_adapters.py index 5a8f6f54..095ab315 100644 --- a/tests/test_gateway_adapters.py +++ b/tests/test_gateway_adapters.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations from typing import Any, Mapping diff --git a/tests/test_gateway_consumer_loop.py b/tests/test_gateway_consumer_loop.py index 3418eae0..a6a5b3af 100644 --- a/tests/test_gateway_consumer_loop.py +++ b/tests/test_gateway_consumer_loop.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for GatewayServer consumer loop — BackendEvent routing.""" from __future__ import annotations diff --git a/tests/test_gateway_tool_e2e.py b/tests/test_gateway_tool_e2e.py index fcec696a..c14ca351 100644 --- a/tests/test_gateway_tool_e2e.py +++ b/tests/test_gateway_tool_e2e.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations import asyncio diff --git a/tests/test_hardware_alert_and_observability.py b/tests/test_hardware_alert_and_observability.py index 29ee3733..ffb8a42d 100644 --- a/tests/test_hardware_alert_and_observability.py +++ b/tests/test_hardware_alert_and_observability.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for Phase 2: alert policy, metrics exporter, and calibration events. Covers the three sub-items delivered together: diff --git a/tests/test_hardware_context.py b/tests/test_hardware_context.py index b6919baf..a4dcabab 100644 --- a/tests/test_hardware_context.py +++ b/tests/test_hardware_context.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hardware context domain model and registry admission rules. Admission is where a declaration becomes something the agent may act on, so each diff --git a/tests/test_hardware_governance.py b/tests/test_hardware_governance.py index 68453803..c22ba703 100644 --- a/tests/test_hardware_governance.py +++ b/tests/test_hardware_governance.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """End-to-end governance chain for hardware commands. Every case here drives the *production* ``ApprovalOrchestrator``, ``ApprovalPolicyEngine``, diff --git a/tests/test_hardware_host_discovery.py b/tests/test_hardware_host_discovery.py index 6dd6bd07..2885087d 100644 --- a/tests/test_hardware_host_discovery.py +++ b/tests/test_hardware_host_discovery.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Host resource discovery and the hot-plug reconcile path. Two subjects that share a file because they share a failure mode: both are about the diff --git a/tests/test_hardware_integration.py b/tests/test_hardware_integration.py index 673fcb8d..75033cc2 100644 --- a/tests/test_hardware_integration.py +++ b/tests/test_hardware_integration.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """L2 integration: real ReadingStore + real DuckDB + real EventBus + MockTransport. Cross-boundary assertion gap (CBAG) regression suite for G15, G16, and G24. diff --git a/tests/test_hardware_longevity.py b/tests/test_hardware_longevity.py index d5174afe..5583ce3e 100644 --- a/tests/test_hardware_longevity.py +++ b/tests/test_hardware_longevity.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Long-run persistence properties of :class:`ReadingStore`, in milliseconds. A bench that runs for a shift or a week is where storage either stays bounded or diff --git a/tests/test_hardware_media.py b/tests/test_hardware_media.py index 57e87f6f..532a67ef 100644 --- a/tests/test_hardware_media.py +++ b/tests/test_hardware_media.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Media devices: discovery, the frame protocol, the privacy gate, and the preview lease. Hermetic by construction. Nothing here opens a camera, a microphone or a display: the diff --git a/tests/test_hardware_observability.py b/tests/test_hardware_observability.py index d7505394..2cfce32d 100644 --- a/tests/test_hardware_observability.py +++ b/tests/test_hardware_observability.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """The physical-bench board: contract, derivation, wiring, and what it renders. Grouped by the claim each assertion defends rather than by module, because the diff --git a/tests/test_hardware_outcome.py b/tests/test_hardware_outcome.py index d868adf6..c8661827 100644 --- a/tests/test_hardware_outcome.py +++ b/tests/test_hardware_outcome.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Physical outcome learning: numeric prediction error and parameter reuse. This is the payoff for connecting hardware to the world model, and the reason the physical diff --git a/tests/test_hardware_reading_store.py b/tests/test_hardware_reading_store.py index fb25d8cd..0d2536c6 100644 --- a/tests/test_hardware_reading_store.py +++ b/tests/test_hardware_reading_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Durable persistence of sampled hardware readings. This closes the gap that made every form of learning from physical experience diff --git a/tests/test_hardware_replay_audit.py b/tests/test_hardware_replay_audit.py index 97eed3b9..eb981e86 100644 --- a/tests/test_hardware_replay_audit.py +++ b/tests/test_hardware_replay_audit.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for ReadingReplay and HardwareAuditLog (Phase 2.5). Covers: diff --git a/tests/test_hardware_signal_path.py b/tests/test_hardware_signal_path.py index d3db7348..18ea9d4d 100644 --- a/tests/test_hardware_signal_path.py +++ b/tests/test_hardware_signal_path.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """The path a derived hardware event actually travels, end to end. Every assertion here checks a *connection*, not a capability. The defect this file diff --git a/tests/test_hardware_stream.py b/tests/test_hardware_stream.py index 30ff8f36..4ece09d7 100644 --- a/tests/test_hardware_stream.py +++ b/tests/test_hardware_stream.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Continuous sampling: the ring, the detector, and the signal source. The layering under test is a boundary decision, not an optimisation. Raw readings must diff --git a/tests/test_hardware_transport_contract.py b/tests/test_hardware_transport_contract.py index 1d445bea..1f6b1278 100644 --- a/tests/test_hardware_transport_contract.py +++ b/tests/test_hardware_transport_contract.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Transport conformance suite -- the executable definition of pluggability. Every registered transport must pass these cases. That is the point: when a driver diff --git a/tests/test_hardware_write_preview.py b/tests/test_hardware_write_preview.py index 37d29511..ea0ccd24 100644 --- a/tests/test_hardware_write_preview.py +++ b/tests/test_hardware_write_preview.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Dry-run preview for hardware writes (Phase 1.5). A preview must run the full feasibility chain -- envelope, rate, reachability, diff --git a/tests/test_im_signal_sources.py b/tests/test_im_signal_sources.py index 6e052b3a..ba5a9af3 100644 --- a/tests/test_im_signal_sources.py +++ b/tests/test_im_signal_sources.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for SlackBotSignalSource and DiscordBotSignalSource. Verifies ActiveSignalSource protocol conformance, URL verification handling, diff --git a/tests/test_inert_wiring_audit.py b/tests/test_inert_wiring_audit.py new file mode 100644 index 00000000..d9d654c3 --- /dev/null +++ b/tests/test_inert_wiring_audit.py @@ -0,0 +1,212 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The audit of capability that was built and never ran, and the two hops it closed. + +Three defects of one shape shipped in a single week with a green suite: the lifecycle +governor was never constructed, the durable trust ledger was never handed to it, and the +hardware trust gate was linked to a ledger that was always ``None``. The audit that found +them lives at ``tools/audit_inert_wiring.py``; these are the regressions. + +What makes the shape invisible is that every unit test constructs the collaborator itself, +so the logic is exercised and the wiring never is. The tests here go through the production +resolvers on purpose. +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +from leapflow.domain.adaptation_verdict import AdaptationVerdict +from leapflow.learning.capability_gap_detector import CapabilityGapDetector +from leapflow.learning.degradation_sink import build_proposal_sink +from leapflow.storage.capability_proposal_queue import JsonCapabilityProposalQueue + + +def _proposal(capability: str, *, risk: str = "read_only") -> Any: + verdict = AdaptationVerdict.create( + "acquire", capability, f"nothing installed serves {capability}", max_risk_level=risk + ) + return CapabilityGapDetector().proposal_from_evolution_intent(verdict.to_intent()) + + +# ── the acquisition chain's last hop ────────────────────────────────────────── + + +def test_an_acquire_verdict_reaches_the_proposal_queue(tmp_path): + """Without this the chain stopped at a requirement nothing enqueued. + + Resolution would report the capability unmet forever, so the teacher's most expensive + verdict -- the only one that leads to code -- had no effect whatsoever. + """ + queue = JsonCapabilityProposalQueue(tmp_path / "q.json") + identifier = build_proposal_sink(queue=queue)(_proposal("mail.send")) + + assert identifier + items = queue.list_items() + assert len(items) == 1 + assert items[0].status == "PENDING", "queueing is not acting; approval still gates it" + assert items[0].source == "world_model" + assert items[0].requirements[0]["capability"] == "mail.send" + + +def test_the_same_capability_does_not_pile_up_across_sessions(tmp_path): + """The queue deduplicates on a hash of the requirement payload. + + Minting a fresh ``requirement_id`` on each rebuild defeated that silently: a reviewer + would face a growing pile of identical items, and the queue's depth would measure how + long the process had been running rather than how much was outstanding. + """ + queue = JsonCapabilityProposalQueue(tmp_path / "q.json") + sink = build_proposal_sink(queue=queue) + + first = sink(_proposal("mail.send")) + second = sink(_proposal("mail.send")) + other = sink(_proposal("chat.reply")) + + assert first == second, "the same capability must resolve to the same proposal" + assert other != first + assert len(queue.list_items()) == 2 + + +def test_the_queued_requirement_keeps_the_clamped_risk(tmp_path): + """``CapabilityRequirement`` defaults ``max_risk_level`` to ``external``. + + That is the most permissive value there is, so omitting it would let a proposal clamped + to ``read_only`` enter the queue asking for everything -- the exact opposite of what + the clamp exists for. + """ + queue = JsonCapabilityProposalQueue(tmp_path / "q.json") + build_proposal_sink(queue=queue)(_proposal("shell.run", risk="external")) + + requirement = queue.list_items()[0].requirements[0] + assert requirement["max_risk_level"] == "read_only", "the model cannot widen its own ask" + + +def test_a_proposal_without_a_capability_is_refused(tmp_path): + """The queue has nothing to deduplicate on and resolution nothing to satisfy.""" + queue = JsonCapabilityProposalQueue(tmp_path / "q.json") + sink = build_proposal_sink(queue=queue) + + assert sink(SimpleNamespace(evidence=(), proposal_id="p1")) == "" + assert queue.list_items() == [] + + +def test_a_failing_queue_does_not_fail_the_session(tmp_path): + """Queueing improves the next session; it must never break this one.""" + + class _Broken: + def enqueue(self, **kwargs): + raise OSError("disk full") + + assert build_proposal_sink(queue=_Broken())(_proposal("mail.send")) == "" + + +# ── the audit itself, kept honest ───────────────────────────────────────────── + + +def test_the_audit_knows_all_three_supply_channels(): + """Two earlier versions of the audit reported wired code as inert. + + They only recognised keyword arguments, so ``EvolutionMemoryProvider`` (supplied by + direct attribute assignment) and ``_reentry_event_observer`` (supplied by ``setattr`` + from the daemon) both looked dead. An audit that cries wolf gets ignored, which is + worse than not having one. + """ + source = ( + Path(__file__).resolve().parent.parent + / "tools" + / "audit_inert_wiring.py" + ).read_text(encoding="utf-8") + + for channel in ("keyword", "attribute", "setattr"): + assert f'"{channel}"' in source, channel + # And it must admit what it cannot see, or a clean report reads as proof. + assert "positional" in source + + +def test_the_audit_runs_and_reports_a_bounded_set(): + """A regression that keeps the audit executable, not just present. + + Run for its exit status and shape rather than an exact count: the count is expected to + move as wiring changes, and pinning it would turn every legitimate fix into a failure. + """ + import subprocess + import sys + + root = Path(__file__).resolve().parent.parent + result = subprocess.run( + [sys.executable, "tools/audit_inert_wiring.py"], + cwd=root, + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, result.stderr[-500:] + assert "A1" in result.stdout and "A2" in result.stdout and "A3" in result.stdout + + +# ── the one-way link the audit flagged, checked rather than assumed ──────────── + + +def test_generalised_patterns_do_reach_the_durable_store(): + """The audit flagged this as write-only; it is not, and the check is the record. + + ``EvolutionMemoryProvider`` is constructed with only ``max_episodes``, so the + constructor parameter ``persistent_store`` looked unsupplied -- one of the shapes that + hid three real defects this week. Here the store arrives by post-construction + assignment instead (``cli/context.py``: ``self._evolution._persistent_store = ...``), + which is a legitimate channel, and generalisation does write through it. + + Worth a test rather than a note, because the first attempt to verify it by hand + concluded the opposite: the probe used a different action sequence per episode, so no + common pattern could be generalised and nothing was written. The link was fine and the + probe was wrong -- exactly the way an audit finding becomes a phantom fix. + """ + from leapflow.memory.providers.evolution import EvolutionMemoryProvider + + written: list[dict[str, Any]] = [] + + class _Store: + def save_pattern(self, **kwargs: Any) -> None: + written.append(kwargs) + + provider = EvolutionMemoryProvider(max_episodes=50) + provider._persistent_store = _Store() + + # A *repeated* sequence: generalisation looks for what the episodes share, so varying + # the actions produces no pattern and therefore no write. + for _ in range(4): + provider.record_episode( + skill_name="chat.reply", + actions=[{"tool": "chat_reply", "step": 1}, {"tool": "verify", "step": 2}], + outcome="ok", + reward=1.0, + ) + + assert written, "a generalised pattern must reach the store" + assert written[-1]["skill_name"] == "chat.reply" + assert written[-1]["episode_count"] >= 3 + + +def test_varying_actions_generalise_to_nothing(): + """The negative half, so the test above cannot pass for the wrong reason.""" + from leapflow.memory.providers.evolution import EvolutionMemoryProvider + + written: list[dict[str, Any]] = [] + + class _Store: + def save_pattern(self, **kwargs: Any) -> None: + written.append(kwargs) + + provider = EvolutionMemoryProvider(max_episodes=50) + provider._persistent_store = _Store() + for index in range(8): + provider.record_episode( + skill_name="chat.reply", + actions=[{"tool": f"tool_{index}", "step": index}], + outcome="ok", + reward=1.0, + ) + + assert written == [], "episodes with nothing in common have no pattern to persist" diff --git a/tests/test_internal_defect_reporting.py b/tests/test_internal_defect_reporting.py index c2936d99..c64c0ff6 100644 --- a/tests/test_internal_defect_reporting.py +++ b/tests/test_internal_defect_reporting.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Contracts for how a defect inside LeapFlow is reported, not laundered. Written after an outage where one mistyped attribute name made the agent unusable diff --git a/tests/test_journey_harness.py b/tests/test_journey_harness.py index be7c1f84..8b1c639d 100644 --- a/tests/test_journey_harness.py +++ b/tests/test_journey_harness.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the end-to-end harness itself. The harness is the foundation the whole real layer stands on, so it gets the diff --git a/tests/test_layout.py b/tests/test_layout.py index c8712aa8..51f00875 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations from leapflow.layout import build_layout, workspace_id_for_path diff --git a/tests/test_lifecycle_governor.py b/tests/test_lifecycle_governor.py index 3b729aae..3f1a7f13 100644 --- a/tests/test_lifecycle_governor.py +++ b/tests/test_lifecycle_governor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for adaptive lifecycle governance.""" from __future__ import annotations diff --git a/tests/test_llm_coevolution_e2e.py b/tests/test_llm_coevolution_e2e.py index 15243b24..0bc004f2 100644 --- a/tests/test_llm_coevolution_e2e.py +++ b/tests/test_llm_coevolution_e2e.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """End-to-end demonstration of LLM co-evolution. Verifies the complete loop: diff --git a/tests/test_llm_provider_registry.py b/tests/test_llm_provider_registry.py index bfe40389..bfbde5f9 100644 --- a/tests/test_llm_provider_registry.py +++ b/tests/test_llm_provider_registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Comprehensive tests for LLMProviderRegistry and ScopedLLMProviderRegistry. Covers: diff --git a/tests/test_marketplace_server.py b/tests/test_marketplace_server.py index e9ee60bf..2b7dffc0 100644 --- a/tests/test_marketplace_server.py +++ b/tests/test_marketplace_server.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the marketplace HTTP server. Verifies that the minimal asyncio-based HTTP server correctly serves diff --git a/tests/test_marketplace_signing.py b/tests/test_marketplace_signing.py index 91ccf0f7..7be486d8 100644 --- a/tests/test_marketplace_signing.py +++ b/tests/test_marketplace_signing.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for Ed25519 signing and verification in the Plugin Marketplace.""" from __future__ import annotations diff --git a/tests/test_mcp_governance.py b/tests/test_mcp_governance.py index c3c18e52..af36ff98 100644 --- a/tests/test_mcp_governance.py +++ b/tests/test_mcp_governance.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Governance for tools supplied by external MCP servers. An MCP tool is third-party code reached over a local transport, running with this diff --git a/tests/test_memory_and_storage.py b/tests/test_memory_and_storage.py index da64a215..4ee13fe2 100644 --- a/tests/test_memory_and_storage.py +++ b/tests/test_memory_and_storage.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scenario-based tests for the memory subsystem and storage layer.""" from __future__ import annotations diff --git a/tests/test_mock_hardware_signals.py b/tests/test_mock_hardware_signals.py index 0166e92d..99dd5677 100644 --- a/tests/test_mock_hardware_signals.py +++ b/tests/test_mock_hardware_signals.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Contract tests for the mock ``HardwareSignalGenerator``. The mock signal framework lives outside ``src/`` and deliberately does not import diff --git a/tests/test_monitor_signal_noise.py b/tests/test_monitor_signal_noise.py index f7c25a9b..ff26aaa1 100644 --- a/tests/test_monitor_signal_noise.py +++ b/tests/test_monitor_signal_noise.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for daemon MonitorCoordinator signal-noise boundary behavior.""" from __future__ import annotations diff --git a/tests/test_monitor_subsystem.py b/tests/test_monitor_subsystem.py index 5a8c32d6..5c8d04d6 100644 --- a/tests/test_monitor_subsystem.py +++ b/tests/test_monitor_subsystem.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic tests for the domain-neutral monitor subsystem (Watch -> Finding). No network, no LLM: uses a temporary DuckDB and a fake in-process producer. diff --git a/tests/test_multi_client_session_isolation.py b/tests/test_multi_client_session_isolation.py index 42eb9bb4..dddbf438 100644 --- a/tests/test_multi_client_session_isolation.py +++ b/tests/test_multi_client_session_isolation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Isolation contracts for two TUI clients on one daemon. Written after two TUIs in different workspaces became unusable in the second one: diff --git a/tests/test_observation_lifecycle.py b/tests/test_observation_lifecycle.py index 87e313c5..a119ebc6 100644 --- a/tests/test_observation_lifecycle.py +++ b/tests/test_observation_lifecycle.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """LF-11: the observation lifecycle is no longer write-only. `JsonCapabilityObservationStore.mark_status` existed with **zero callers and zero diff --git a/tests/test_orientation.py b/tests/test_orientation.py index 8c64c963..c9b40736 100644 --- a/tests/test_orientation.py +++ b/tests/test_orientation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """S4-D1: multi-layer orientation aggregation (observe-only). Hermetic unit tests for the pure orientation aggregator and the research-ledger diff --git a/tests/test_path_sensitivity.py b/tests/test_path_sensitivity.py index b8449e26..08df91cc 100644 --- a/tests/test_path_sensitivity.py +++ b/tests/test_path_sensitivity.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations from pathlib import Path diff --git a/tests/test_perception_pipeline.py b/tests/test_perception_pipeline.py index 60d64bc1..b60bf388 100644 --- a/tests/test_perception_pipeline.py +++ b/tests/test_perception_pipeline.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scenario-based tests for the perception pipeline (causal, signal fusion). Covers causal chain construction, graph operations, heuristic priors, diff --git a/tests/test_phase3_learning_autonomy.py b/tests/test_phase3_learning_autonomy.py index a1ab6071..f0544159 100644 --- a/tests/test_phase3_learning_autonomy.py +++ b/tests/test_phase3_learning_autonomy.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Phase 3 learning-layer tests: prediction physical branch, EMA bias, causal rules, hardware trust gate, MCP capability validation. """ diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index a69e47f1..be424e26 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Platform adapter return-shape contracts (cua-driver, verified against 0.6.8). Locks the response side of the wire contract: get_window_state's flat diff --git a/tests/test_platform_synthesis.py b/tests/test_platform_synthesis.py index 2e51b670..692a88fe 100644 --- a/tests/test_platform_synthesis.py +++ b/tests/test_platform_synthesis.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scenario-based tests for platform synthesis (denoise + synthesis + intent inference). Replaces granular rule-level tests in test_synthesis.py, test_denoise.py, diff --git a/tests/test_plugin_behavior_tests.py b/tests/test_plugin_behavior_tests.py index cf24c9ae..05bc89ba 100644 --- a/tests/test_plugin_behavior_tests.py +++ b/tests/test_plugin_behavior_tests.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for proposal-defined plugin behavior checks.""" from __future__ import annotations diff --git a/tests/test_plugin_generator.py b/tests/test_plugin_generator.py index 40d310a6..554357d2 100644 --- a/tests/test_plugin_generator.py +++ b/tests/test_plugin_generator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for LLM-driven plugin generation and validation pipeline.""" from __future__ import annotations diff --git a/tests/test_plugin_learning.py b/tests/test_plugin_learning.py index 3a8c5e46..c545ec30 100644 --- a/tests/test_plugin_learning.py +++ b/tests/test_plugin_learning.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Comprehensive tests for the Learning Plugin Evolution integration. Covers: diff --git a/tests/test_plugin_marketplace.py b/tests/test_plugin_marketplace.py index 6e52eab4..a3f5f822 100644 --- a/tests/test_plugin_marketplace.py +++ b/tests/test_plugin_marketplace.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the plugin marketplace (discover, verify, install external plugins).""" from __future__ import annotations diff --git a/tests/test_plugin_plan_introspection.py b/tests/test_plugin_plan_introspection.py index bd58faea..9e301e9c 100644 --- a/tests/test_plugin_plan_introspection.py +++ b/tests/test_plugin_plan_introspection.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for plugin adaptive plan introspection surfaces.""" from __future__ import annotations diff --git a/tests/test_plugin_proposal_store.py b/tests/test_plugin_proposal_store.py index 07495f5c..e33c7de6 100644 --- a/tests/test_plugin_proposal_store.py +++ b/tests/test_plugin_proposal_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for profile-scoped plugin proposal persistence.""" from __future__ import annotations diff --git a/tests/test_plugin_reload.py b/tests/test_plugin_reload.py index e78d1aba..9228c63f 100644 --- a/tests/test_plugin_reload.py +++ b/tests/test_plugin_reload.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Comprehensive tests for plugin reload lifecycle. Covers: diff --git a/tests/test_plugin_sandbox.py b/tests/test_plugin_sandbox.py index b434b6d6..16c18b48 100644 --- a/tests/test_plugin_sandbox.py +++ b/tests/test_plugin_sandbox.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the plugin sandbox (process isolation for untrusted plugins).""" from __future__ import annotations diff --git a/tests/test_plugin_stats_persistence.py b/tests/test_plugin_stats_persistence.py index f4c058dc..4e609bc9 100644 --- a/tests/test_plugin_stats_persistence.py +++ b/tests/test_plugin_stats_persistence.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for durable plugin trust persistence (Fix D2). Covers the DuckDB-backed ``PluginStatsStore`` round-trip, the diff --git a/tests/test_plugin_version_store.py b/tests/test_plugin_version_store.py index a31e0448..278e5627 100644 --- a/tests/test_plugin_version_store.py +++ b/tests/test_plugin_version_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for profile-scoped plugin version store.""" from __future__ import annotations diff --git a/tests/test_process_group.py b/tests/test_process_group.py index fb8611e7..57df9af3 100644 --- a/tests/test_process_group.py +++ b/tests/test_process_group.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for leapflow.utils.process_group — cross-platform tree termination.""" from __future__ import annotations diff --git a/tests/test_pure_algorithms.py b/tests/test_pure_algorithms.py index dcfb0d1c..065f1bcc 100644 --- a/tests/test_pure_algorithms.py +++ b/tests/test_pure_algorithms.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Cherry-picked pure algorithm tests — deterministic, stateless primitives.""" from __future__ import annotations diff --git a/tests/test_recovery_audit.py b/tests/test_recovery_audit.py index ab9cca88..ca03162c 100644 --- a/tests/test_recovery_audit.py +++ b/tests/test_recovery_audit.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for recovery_audit module — structured audit logging for recovery decisions.""" from __future__ import annotations diff --git a/tests/test_recovery_checkpoint.py b/tests/test_recovery_checkpoint.py index 79ca6f3e..71651889 100644 --- a/tests/test_recovery_checkpoint.py +++ b/tests/test_recovery_checkpoint.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the recovery checkpoint system (cross-turn state persistence).""" from __future__ import annotations diff --git a/tests/test_recovery_contract_e2e.py b/tests/test_recovery_contract_e2e.py index 5fe49cac..05f2649b 100644 --- a/tests/test_recovery_contract_e2e.py +++ b/tests/test_recovery_contract_e2e.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """End-to-end guards for the recovery contracts in AGENTS.md. The existing recovery tests are unit-level: they exercise one budget method or diff --git a/tests/test_recovery_coordinator.py b/tests/test_recovery_coordinator.py index 8546e2f2..54fa4180 100644 --- a/tests/test_recovery_coordinator.py +++ b/tests/test_recovery_coordinator.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Comprehensive tests for the P0 recovery coordinator subsystem. Covers: diff --git a/tests/test_recovery_strategies.py b/tests/test_recovery_strategies.py index 1c19cb68..2bdd8d1c 100644 --- a/tests/test_recovery_strategies.py +++ b/tests/test_recovery_strategies.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for built-in recovery strategies. Covers each strategy: diff --git a/tests/test_reentry_driver.py b/tests/test_reentry_driver.py index f957ec0b..0b8069c0 100644 --- a/tests/test_reentry_driver.py +++ b/tests/test_reentry_driver.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unit tests for the S2 re-entry driver (phase N3). Hermetic: DuckDB re-entry store on a temp file + a stub async runner; no engine, diff --git a/tests/test_reentry_send.py b/tests/test_reentry_send.py index 1fa0b8a3..1dc47d49 100644 --- a/tests/test_reentry_send.py +++ b/tests/test_reentry_send.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """S2 outbound SO1+SO2+SO4: governance kernel for autonomous re-entry sends. Hermetic unit tests for the pure decision primitives — contracts + target diff --git a/tests/test_reentry_service.py b/tests/test_reentry_service.py index b8c54b9c..ca0cfe30 100644 --- a/tests/test_reentry_service.py +++ b/tests/test_reentry_service.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unit tests for the S2 re-entry service (phases N3b–N5): time + event dispatch, global-budget backstop, disabled gating, and the N5 no-external-send guarantee. diff --git a/tests/test_reentry_store.py b/tests/test_reentry_store.py index 8b475c45..48b62bb0 100644 --- a/tests/test_reentry_store.py +++ b/tests/test_reentry_store.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unit tests for the S2 re-entry store (phase N1, pure storage layer). Hermetic: DuckDB on a temp file, no engine / gateway / network. diff --git a/tests/test_reorder_buffer_capacity.py b/tests/test_reorder_buffer_capacity.py index 5d788df9..b83f200e 100644 --- a/tests/test_reorder_buffer_capacity.py +++ b/tests/test_reorder_buffer_capacity.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for EventReorderBuffer capacity hard limit (Task #4).""" from __future__ import annotations diff --git a/tests/test_repo_map.py b/tests/test_repo_map.py index 9f0a6259..4824ea93 100644 --- a/tests/test_repo_map.py +++ b/tests/test_repo_map.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the repo_map project-orientation tool (C1).""" from __future__ import annotations diff --git a/tests/test_runtime_metadata_and_wrapping.py b/tests/test_runtime_metadata_and_wrapping.py index a2069192..68322179 100644 --- a/tests/test_runtime_metadata_and_wrapping.py +++ b/tests/test_runtime_metadata_and_wrapping.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Guards for runtime metadata reporting and long-output rendering. Two failures that kept coming back, both because a value was read from the wrong diff --git a/tests/test_safety_and_policy.py b/tests/test_safety_and_policy.py index b24a6bf8..0f32b643 100644 --- a/tests/test_safety_and_policy.py +++ b/tests/test_safety_and_policy.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scenario-based tests for safety: confirmation levels, action policy, sandbox.""" from __future__ import annotations diff --git a/tests/test_scm_tools.py b/tests/test_scm_tools.py index 51c5df87..f8cc75e3 100644 --- a/tests/test_scm_tools.py +++ b/tests/test_scm_tools.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations import pytest diff --git a/tests/test_scoped_registry.py b/tests/test_scoped_registry.py index f72688c3..0648d8c0 100644 --- a/tests/test_scoped_registry.py +++ b/tests/test_scoped_registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Integration tests for scoped lifecycle wrappers around registries.""" from __future__ import annotations diff --git a/tests/test_selection_policy.py b/tests/test_selection_policy.py new file mode 100644 index 00000000..ff6050eb --- /dev/null +++ b/tests/test_selection_policy.py @@ -0,0 +1,607 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""P1: the selection policy seam. + +The load-bearing test here is equivalence. Introducing a seam must change no +behaviour, so ``GreedyPolicy`` is checked against the rule it replaced -- highest +weighted score, ties broken by a stable sort on ``(plugin_id, tool_name)`` -- rather +than described as similar to it. Everything else guards a property that only matters +once a *second* policy exists, which is exactly when it is too late to add the guard. +""" + +from __future__ import annotations + +import pytest + +from types import SimpleNamespace + +from leapflow.domain.capability_requirement import CapabilityRequirement +from leapflow.domain.environment_fingerprint import EnvironmentFingerprint +from leapflow.domain.platform import Capability, PlatformID, PlatformManifest +from leapflow.plugins._builtin_policies import GreedyPolicy, GreedyPolicyPlugin +from leapflow.plugins.capability_resolver import ( + CapabilityCandidate, + CapabilityResolver, + ResolverContext, +) +from leapflow.plugins.selection_policy import ( + PolicyDeps, + RewardSignal, + SelectionOutcome, + SelectionPolicy, + SelectionPolicyPlugin, +) +from leapflow.plugins.selection_policy_registry import ( + DEFAULT_POLICY_ID, + SelectionPolicyRegistry, + get_selection_policy_registry, + reset_selection_policy_registry, +) + + +def _req(capability: str = "json.pretty") -> CapabilityRequirement: + return CapabilityRequirement.create(capability, "unknown_tool") + + +def _env(*caps: Capability) -> EnvironmentFingerprint: + return EnvironmentFingerprint.from_platform_manifest( + PlatformManifest(PlatformID.DARWIN_15, "15.0", frozenset(caps)) + ) + + +def _candidate(plugin_id: str, tool_name: str, **kw) -> CapabilityCandidate: + return CapabilityCandidate( + plugin_id=plugin_id, + tool_name=tool_name, + provides_capabilities=kw.pop("provides", ("json.pretty",)), + requires_platform_capabilities=kw.pop("requires", ()), + risk_level=kw.pop("risk_level", "read_only"), + **kw, + ) + + +def _settings(**kw) -> SimpleNamespace: + """A minimal live-settings stand-in. + + The registry reads configuration off an object rather than the global snapshot, + so a test supplies its own instead of mutating process state. + """ + return SimpleNamespace( + selection_policy=kw.get("policy", "greedy"), + selection_prior_strength=kw.get("prior_strength", 2.0), + selection_exploration=kw.get("exploration", 1.0), + selection_buckets=kw.get("buckets", ""), + ) + + +def _ctx() -> ResolverContext: + return ResolverContext(environment=_env(Capability.FILE_OPS)) + + +@pytest.fixture(autouse=True) +def _isolated_registry(): + """The registry is a process singleton; a test must not inherit another's policy.""" + reset_selection_policy_registry() + yield + reset_selection_policy_registry() + + +# ── equivalence with the behaviour the seam replaced ────────────────────────── + + +def test_greedy_picks_the_highest_score(): + candidates = ( + _candidate("a", "tool_a", risk_level="high"), + _candidate("b", "tool_b", risk_level="read_only"), + ) + resolution = CapabilityResolver().resolve_one(_req(), candidates, _ctx()) + + assert resolution.selected is not None + assert resolution.selected.candidate.tool_name == "tool_b" + assert resolution.policy_id == "greedy" + + +def test_greedy_breaks_a_tie_by_the_previous_stable_rule(): + """``(plugin_id, tool_name)`` ascending -- the exact rule the resolver used. + + Order of the input must not matter: a tie resolved by arrival order would make + selection depend on registry iteration, which changes as plugins load. + """ + forward = (_candidate("a", "tool_a"), _candidate("b", "tool_b")) + reverse = tuple(reversed(forward)) + + for candidates in (forward, reverse): + resolution = CapabilityResolver().resolve_one(_req(), candidates, _ctx()) + assert resolution.selected is not None + assert resolution.selected.candidate.plugin_id == "a" + + +def test_greedy_never_reports_an_exploration(): + """``explored`` is what distinguishes deliberate exploration from a scoring bug.""" + resolution = CapabilityResolver().resolve_one( + _req(), (_candidate("a", "tool_a"),), _ctx() + ) + assert resolution.explored is False + + +def test_a_resolution_records_which_policy_chose(): + """Without this the decision history stops being auditable once policies vary.""" + resolution = CapabilityResolver().resolve_one( + _req(), (_candidate("a", "tool_a"),), _ctx() + ) + assert resolution.policy_id == "greedy" + assert "greedy" in resolution.reason + assert resolution.to_dict()["policy_id"] == "greedy" + + +# ── the safety boundary ─────────────────────────────────────────────────────── + + +class _PicksLast: + """A policy that would take the worst candidate, to prove it cannot reach one.""" + + policy_id = "picks_last" + + def __init__(self) -> None: + self.seen: list[str] = [] + + def select(self, requirement, eligible, context) -> SelectionOutcome: + self.seen = [c.candidate.tool_name for c in eligible] + return SelectionOutcome(selected=eligible[-1], policy_id=self.policy_id, explored=True) + + def observe(self, requirement, chosen_tool, reward) -> None: + return None + + +def test_an_excluded_candidate_is_never_offered_to_a_policy(): + """Exploration must not reach a tool a hard constraint refused. + + Safety is not a term to be traded off: a candidate excluded for a missing + platform affordance is filtered out *before* the policy is consulted, so no + policy -- however adventurous -- can select it. + """ + policy = _PicksLast() + candidates = ( + _candidate("a", "tool_a"), + _candidate("b", "needs_vision", requires=(Capability.SCREEN_CAPTURE.value,)), + ) + + resolution = CapabilityResolver(policy=policy).resolve_one(_req(), candidates, _ctx()) + + assert "needs_vision" not in policy.seen, "an inadmissible candidate reached the policy" + assert resolution.selected is not None + assert resolution.selected.candidate.tool_name == "tool_a" + + +def test_a_policy_is_not_consulted_when_nothing_is_admissible(): + """No admissible candidate is a resolver verdict, not a choice to delegate.""" + policy = _PicksLast() + resolution = CapabilityResolver(policy=policy).resolve_one( + _req(), (_candidate("b", "needs_vision", requires=(Capability.SCREEN_CAPTURE.value,)),), _ctx() + ) + + assert policy.seen == [] + assert resolution.unmet is True + assert resolution.policy_id == "" + + +def test_a_policy_that_explores_says_so(): + resolution = CapabilityResolver(policy=_PicksLast()).resolve_one( + _req(), (_candidate("a", "tool_a"), _candidate("b", "tool_b")), _ctx() + ) + assert resolution.explored is True + assert resolution.policy_id == "picks_last" + + +# ── registry ────────────────────────────────────────────────────────────────── + + +class _Fake: + policy_id = "fake" + + def select(self, requirement, eligible, context) -> SelectionOutcome: + return SelectionOutcome(selected=eligible[0], policy_id=self.policy_id) + + def observe(self, requirement, chosen_tool, reward) -> None: + return None + + +class _FakePlugin: + def __init__(self, policy_id: str = "fake", name: str = "Fake") -> None: + self._id = policy_id + self._name = name + self.built_with: PolicyDeps | None = None + + @property + def policy_id(self) -> str: + return self._id + + @property + def display_name(self) -> str: + return self._name + + def create(self, params, deps) -> SelectionPolicy: + self.built_with = deps + return _Fake() + + +def test_the_builtin_greedy_plugin_satisfies_both_protocols(): + assert isinstance(GreedyPolicyPlugin(), SelectionPolicyPlugin) + assert isinstance(GreedyPolicy(), SelectionPolicy) + + +def test_greedy_is_registered_and_is_the_default(): + registry = get_selection_policy_registry() + assert DEFAULT_POLICY_ID in registry.available() + assert registry.activate().policy_id == DEFAULT_POLICY_ID + + +def test_first_registration_of_an_id_wins_and_the_collision_is_recorded(): + """A silently replaced policy would change how the framework chooses its own tools. + + Non-fatal on purpose, like tool-name arbitration: one colliding package must not + stop every other policy from registering. + """ + registry = SelectionPolicyRegistry() + assert registry.register(_FakePlugin(name="incumbent")) is True + assert registry.register(_FakePlugin(name="challenger")) is False + + assert [r["kept"] for r in registry.rejected] == ["incumbent"] + assert [r["rejected"] for r in registry.rejected] == ["challenger"] + + +def test_an_unknown_configured_policy_falls_back_to_the_default(): + """A misconfiguration is a reason to log, never a reason to stop choosing tools.""" + registry = SelectionPolicyRegistry() + from leapflow.plugins._builtin_policies import register_builtin_policies + + register_builtin_policies(registry) + + policy = registry.create_from_config({"selection_policy": "does_not_exist"}) + + assert policy is not None + assert policy.policy_id == DEFAULT_POLICY_ID + + +def test_a_policy_that_fails_to_build_does_not_break_selection(): + class _Broken(_FakePlugin): + def create(self, params, deps): + raise RuntimeError("boom") + + registry = SelectionPolicyRegistry() + registry.register(_Broken("broken")) + + assert registry.create("broken") is None + + +def test_deps_reach_the_plugin_that_builds_the_policy(): + registry = SelectionPolicyRegistry() + plugin = _FakePlugin() + registry.register(plugin) + ledger = object() + + registry.create("fake", {}, PolicyDeps(trust_ledger=ledger)) + + assert plugin.built_with is not None + assert plugin.built_with.trust_ledger is ledger + + +# ── instance ownership: the trap that makes a learning policy never learn ───── + + +def test_activation_caches_so_a_stateful_policy_accumulates(): + """A fresh instance per call would hand each observation to a throwaway object. + + Silent, because every individual call looks correct -- which is why the cache + lives in the registry rather than at each call site. + """ + registry = get_selection_policy_registry() + first = registry.activate() + assert first is not None + assert registry.activate() is first + assert registry.current() is first + + +def test_current_never_creates_a_policy(): + """A reporter must not install a dependency-less policy ahead of the real owner. + + ``current()`` answering ``None`` is correct: nothing has selected yet, so there + is no decision to report on. + """ + registry = get_selection_policy_registry() + assert registry.current() is None + + registry.activate(PolicyDeps(trust_ledger=object())) + assert registry.current() is not None + + +def test_the_registered_set_is_discoverable_for_the_config_hint(): + """``selection.policy`` accepts an id, so the ids must be findable. + + Read by the config catalog rather than hardcoded there: a policy registered by a + third-party package through the entry point group has to appear too, and a literal + enumeration in the catalog would silently omit it. + """ + registry = get_selection_policy_registry() + described = {d.policy_id: d.display_name for d in registry.describe()} + + assert set(described) == set(registry.available()) + assert all(described.values()), "every policy needs a human-readable name" + + from leapflow.config_service import _registered_selection_policies + + hint = _registered_selection_policies() + for policy_id in described: + assert policy_id in hint, hint + + +# ── reward semantics ───────────────────────────────────────────────────────── + + +def test_an_abstaining_reward_is_not_a_failure(): + """``None`` means "no information". Folding it into failure would quarantine + healthy plugins for a reporting omission, since a successful call whose handler + declared no effect is the normal state for tools predating the convention. + """ + assert RewardSignal(value=None).informative is False + assert RewardSignal(value=0.0).informative is True + assert RewardSignal(value=1.0).informative is True + + +def test_greedy_accepts_an_observation_and_ignores_it(): + """A no-op rather than an omission, so the feedback edge is uniform. + + Adding a learning policy must be a new file plus a config value, not a change to + the call sites that report outcomes. + """ + policy = GreedyPolicy() + policy.observe(_req(), "tool_a", RewardSignal(value=1.0)) + policy.observe(_req(), "tool_a", RewardSignal(value=None)) + + +def test_a_failing_arbiter_does_not_fail_selection(): + """The arbiter is advisory; an LLM tie-break that raises must not lose the turn.""" + + class _Boom: + def choose(self, requirement, tied, context): + raise RuntimeError("nope") + + resolution = CapabilityResolver(policy=GreedyPolicy(arbiter=_Boom())).resolve_one( + _req(), (_candidate("a", "tool_a"), _candidate("b", "tool_b")), _ctx() + ) + + assert resolution.selected is not None + assert resolution.selected.candidate.plugin_id == "a" # stable fallback + assert resolution.arbitration_used is False + + +# ── the feedback edge, exercised through the real sweep ─────────────────────── + + +class _Recording: + """Records what the sweep reports, to prove the edge is live rather than dead.""" + + policy_id = "recording" + + def __init__(self) -> None: + self.observations: list[tuple[str, float | None, str]] = [] + + def select(self, requirement, eligible, context) -> SelectionOutcome: + return SelectionOutcome(selected=eligible[0], policy_id=self.policy_id) + + def observe(self, requirement, chosen_tool, reward) -> None: + self.observations.append((chosen_tool, reward.value, reward.source)) + + +class _RecordingPlugin: + def __init__(self, policy: _Recording) -> None: + self._policy = policy + + @property + def policy_id(self) -> str: + return "recording" + + @property + def display_name(self) -> str: + return "Recording" + + def create(self, params, deps) -> SelectionPolicy: + return self._policy + + +def test_the_sweep_reports_every_verdict_to_the_active_policy(monkeypatch): + """The edge that was missing: verdicts fed trust but never the chooser. + + Driven through the real ``CoevolutionSweep`` and the real verifier rather than a + stub, because the value of this edge is that it is *wired* -- a hand-built call + would pass while production dropped every reward. + """ + import asyncio + + from leapflow.evolution.sweep import CoevolutionSweep + from leapflow.plugins import selection_policy_registry as reg + + recorder = _Recording() + registry = SelectionPolicyRegistry() + registry.register(_RecordingPlugin(recorder)) + monkeypatch.setattr(reg, "_registry", registry) + monkeypatch.setattr(reg, "_settings_config", lambda: {"selection_policy": "recording"}) + assert registry.activate() is recorder + + req = CapabilityRequirement.create( + "file.write", "unknown_tool", metadata={"expected_effect": "wrote the bytes"} + ) + asyncio.run(CoevolutionSweep().run(verifications=[ + (req, {"ok": True, "observed_effect": "wrote the bytes to disk"}, "good"), + (req, {"ok": False, "error": "boom"}, "bad"), + (req, {"ok": True}, "silent"), + ])) + + by_tool = {tool: value for tool, value, _ in recorder.observations} + assert by_tool["good"] == 1.0, "a verified effect must arrive as a positive reward" + assert by_tool["bad"] == 0.0, "a failure must arrive as a negative reward" + assert by_tool["silent"] is None, "an unverifiable outcome must abstain, not refute" + + +def test_a_reporter_without_an_active_policy_is_silent(monkeypatch): + """No active policy means nothing selected, so there is nothing to report. + + Must not raise, and must not install a dependency-less policy as a side effect. + """ + import asyncio + + from leapflow.evolution.sweep import CoevolutionSweep + from leapflow.plugins import selection_policy_registry as reg + + registry = SelectionPolicyRegistry() + monkeypatch.setattr(reg, "_registry", registry) + + req = CapabilityRequirement.create("file.write", "unknown_tool") + asyncio.run(CoevolutionSweep().run( + verifications=[(req, {"ok": True}, "tool")] + )) + + assert registry.current() is None + + +# ── configuration actually taking effect ────────────────────────────────────── + + +def test_a_switched_policy_takes_effect_without_a_restart(): + """``selection.policy`` presents itself as hot-reloadable, so it must be. + + The live value is *pushed* in, because ``get_settings()`` is a boot snapshot with + no refresh path anywhere in the process -- a policy read from it would be pinned + to whatever configuration existed at startup while ``leap config`` reported the + change as applied. + """ + registry = SelectionPolicyRegistry() + registry.register(_FakePlugin("fake")) + from leapflow.plugins._builtin_policies import register_builtin_policies + + register_builtin_policies(registry) + + assert registry.activate(settings=_settings(policy="greedy")).policy_id == "greedy" + switched = registry.activate(settings=_settings(policy="fake")) + + assert switched is not None + assert switched.policy_id == "fake", "a config change must reach the next selection" + assert registry.current() is switched + + +def test_switching_back_and_forth_is_stable(): + registry = SelectionPolicyRegistry() + registry.register(_FakePlugin("fake")) + from leapflow.plugins._builtin_policies import register_builtin_policies + + register_builtin_policies(registry) + + ids = [registry.activate(settings=_settings(policy=pid)).policy_id for pid in ("greedy", "fake", "greedy")] + assert ids == ["greedy", "fake", "greedy"] + + +def test_an_unknown_id_does_not_thrash_the_active_policy(): + """A misconfiguration must not rebuild the policy on every single activation. + + The fallback answers ``greedy`` while the requested id stays unknown, so a naive + "rebuild when the ids differ" check would re-create and re-log forever -- and a + stateful policy would lose its state on every capability observation. + """ + registry = SelectionPolicyRegistry() + from leapflow.plugins._builtin_policies import register_builtin_policies + + register_builtin_policies(registry) + + first = registry.activate(settings=_settings(policy="nope")) + assert first is not None + assert first.policy_id == "greedy" + assert registry.activate(settings=_settings(policy="nope")) is first, "an unknown id must not rebuild" + + +def test_the_loop_pushes_the_configured_policy_through_to_selection(monkeypatch): + """End to end: the id the engine holds is the policy the resolver ends up using.""" + from leapflow.plugins import selection_policy_registry as reg + from leapflow.plugins.adaptive_loop import AdaptivePluginLoop + + registry = SelectionPolicyRegistry() + registry.register(_FakePlugin("fake")) + monkeypatch.setattr(reg, "_registry", registry) + + class _NoStore: + def append(self, *a, **k) -> None: + return None + + loop = AdaptivePluginLoop( + registry=None, plan_store=_NoStore(), settings=_settings(policy="fake") + ) + + assert registry.current() is not None + assert registry.current().policy_id == "fake" + assert loop is not None + + +def test_the_two_adaptive_scorers_contribute_nothing_without_their_inputs(): + """Measured, not assumed: in the default resolver both learning signals are 0. + + ``AdaptivePluginLoop`` is constructed in the engine without a trust ledger or a + usage tracker, so ``TrustScorer`` reports "trust ledger unavailable" and + ``ReliabilityScorer`` reports "usage tracker unavailable" -- both scoring 0.0 for + every candidate. Selection is therefore decided entirely by the three static + scorers, and any tie falls to alphabetical order. + + This is recorded as a test because it is the precondition for a learning policy: + a bandit placed here would have no differentiating signal to learn from until + those dependencies are wired. Wiring them changes which tool gets selected, so it + is a deliberate decision rather than something to slip into a seam that is + supposed to change no behaviour. + """ + resolution = CapabilityResolver().resolve_one( + _req(), (_candidate("a", "tool_a"), _candidate("b", "tool_b")), _ctx() + ) + + by_scorer = { + component.to_dict()["scorer"]: component.to_dict() + for score in resolution.candidates + for component in score.components + } + assert by_scorer["trust"]["score"] == 0.0 + assert "unavailable" in by_scorer["trust"]["reason"] + assert by_scorer["reliability"]["score"] == 0.0 + assert "unavailable" in by_scorer["reliability"]["reason"] + + totals = {s.candidate.tool_name: s.total_score for s in resolution.candidates} + assert len(set(totals.values())) == 1, "both candidates tie on the static scorers alone" + + +# ── generality: no per-policy hard rules in the shared machinery ─────────────── + + +def test_the_settings_translation_names_no_policy(): + """One shared params dict, so a new policy needs no entry in the translation.""" + from leapflow.plugins.selection_policy_registry import policy_params_from_settings + + assert policy_params_from_settings(_settings()) == {} + for policy_id in ("greedy", "bucketed", "thompson", "ucb1"): + assert policy_id not in policy_params_from_settings(_settings()) + + +def test_a_third_party_policy_can_configure_itself_through_the_open_dict(): + """A policy from the entry point group cannot add typed settings, so the open dict + is its only configuration path. + """ + from leapflow.plugins.selection_policy_registry import policy_params_from_settings + + settings = _settings() + settings.policy_params = {"custom_knob": "on"} + assert policy_params_from_settings(settings)["custom_knob"] == "on" + + +def test_greedy_is_the_only_shipped_policy(): + """The learning policies were removed after measurement, not shipped and forgotten. + + Measured before removal: the policy was consulted 0 times in production, every + capability had exactly one candidate, and reward bound only self-acquired plugins. + The seam stays because re-adding a policy is one file; the policies went because + they optimised a decision that is not being made. + """ + registry = get_selection_policy_registry() + assert registry.available() == ["greedy"] diff --git a/tests/test_self_evolution_switch.py b/tests/test_self_evolution_switch.py new file mode 100644 index 00000000..44ae0d13 --- /dev/null +++ b/tests/test_self_evolution_switch.py @@ -0,0 +1,174 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The self-evolution switch: one setting, off by default, stated on the first screen. + +The world model is not what this gates. It reviews every session, records what it learned +about the environment for the next one, and recommends which installed provider to prefer +-- none of which writes code or changes what the agent is able to do. Switching that off +would cost adaptation and reduce no risk, so it runs unconditionally. + +What is gated is the single branch that writes code: an ``acquire`` verdict becoming a +queued proposal for a new plugin. Off by default because acquiring a capability is the most +expensive and least reversible decision the system makes, and because a user should choose +it rather than discover it after the fact. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +from leapflow.learning.capability_observation import ( + DEFAULT_ACCEPTED_EVIDENCE, + CapabilityEvidenceClassifier, +) + + +def _settings(**overrides: Any) -> SimpleNamespace: + base = {"evolution_enabled": False, "accepted_evidence_kinds": ()} + base.update(overrides) + return SimpleNamespace(**base) + + +# ── the switch itself ───────────────────────────────────────────────────────── + + +def test_self_evolution_is_off_by_default(): + """Acquiring a capability is the least reversible thing the system decides.""" + from leapflow.config import get_settings + + assert get_settings().evolution_enabled is False + + +def test_the_switch_is_discoverable_through_the_config_control_plane(): + """Every durable, user-writable setting must be reachable via ``leap config``. + + And this one gets its own category rather than being folded into Learning or Plugins: + it is the switch a user is most likely to go looking for, so burying it among tuning + knobs would make the most consequential setting the hardest to find. + """ + from leapflow.config_service import _build_field_specs + + spec = _build_field_specs()["evolution.enabled"] + assert spec.value_type is bool + assert spec.category == "Self-Evolution" + assert spec.description and "Off by default" in spec.description + # The description has to say what it does *not* gate, or a user turning it off would + # reasonably expect the world model to stop too. + assert "world model runs either way" in spec.description + + +# ── configuration unification ───────────────────────────────────────────────── + + +def test_one_switch_admits_world_model_evidence(): + """Two knobs were one too many. + + ``accepted_evidence_kinds`` surfaces as ``accepted.evidence_kinds`` -- a section that + names nothing -- so a user who turned self-evolution on and saw nothing happen had no + way to guess that a second, differently-named setting also had to list + ``world_model_intent``. + """ + off = CapabilityEvidenceClassifier.from_settings(_settings()) + on = CapabilityEvidenceClassifier.from_settings(_settings(evolution_enabled=True)) + + assert "world_model_intent" not in off.accepted + assert "world_model_intent" in on.accepted + + +def test_enabling_the_switch_does_not_drop_the_trigger_already_working(): + """``from_kinds`` treats a non-empty tuple as the whole accepted set. + + So appending alone would have *removed* ``unknown_tool``: turning self-evolution on + would have silently disabled the trigger that already worked, and the chain would have + looked more capable while covering less. + """ + for enabled in (False, True): + accepted = CapabilityEvidenceClassifier.from_settings( + _settings(evolution_enabled=enabled) + ).accepted + assert DEFAULT_ACCEPTED_EVIDENCE <= accepted, enabled + + +def test_the_finer_grained_tuple_still_widens_the_set(): + """Structural kinds come from an environment probe, not the world model. + + They stay opted into separately, so the switch is the common path and not a ceiling. + """ + accepted = CapabilityEvidenceClassifier.from_settings( + _settings(evolution_enabled=True, accepted_evidence_kinds=("interface_drift",)) + ).accepted + + assert {"interface_drift", "world_model_intent", "unknown_tool"} <= accepted + + +def test_the_tuple_alone_still_works_without_the_switch(): + """An operator who set only the tuple must not lose that behaviour.""" + accepted = CapabilityEvidenceClassifier.from_settings( + _settings(accepted_evidence_kinds=("world_model_intent",)) + ).accepted + assert "world_model_intent" in accepted + + +def test_a_settings_object_without_the_field_behaves_as_off(): + """Absence must read as off, not as an error: the daemon may predate the field.""" + accepted = CapabilityEvidenceClassifier.from_settings( + SimpleNamespace(accepted_evidence_kinds=()) + ).accepted + assert "world_model_intent" not in accepted + + +# ── stated on the first screen, in both directions ──────────────────────────── + + +class _Console: + def __init__(self) -> None: + self.lines: list[tuple[str, str]] = [] + + def print(self, text: str, **kwargs: Any) -> None: + self.lines.append(("emphasis", text)) + + def system(self, text: str) -> None: + self.lines.append(("plain", text)) + + +def test_the_mode_is_announced_when_it_is_off(): + """Silence would make the quiet default indistinguishable from a build without it. + + A user who cannot tell which they have cannot reason about either. + """ + from leapflow.cli.commands.interactive import _announce_self_evolution + + console = _Console() + _announce_self_evolution(console, _settings()) + + kind, text = console.lines[0] + assert kind == "plain", "the default is not a warning" + assert "Self-evolution off" in text + # And it must say how to change it, or "off" is a dead end. + assert "leap config set evolution.enabled true" in text + + +def test_the_mode_is_emphasised_when_it_is_on(): + """The same class of fact as the approval-bypass notice, in the same place.""" + from leapflow.cli.commands.interactive import _announce_self_evolution + + console = _Console() + _announce_self_evolution(console, _settings(evolution_enabled=True)) + + kind, text = console.lines[0] + assert kind == "emphasis" + assert "Self-evolution on" in text + # Being on is not being unguarded, and the line must not imply otherwise. + assert "needs your approval" in text + + +def test_both_startup_paths_announce_it(): + """In-process and daemon-backed startup must not differ on a safety-relevant mode.""" + from pathlib import Path + + source = ( + Path(__file__).resolve().parent.parent + / "src" / "leapflow" / "cli" / "commands" / "interactive.py" + ).read_text(encoding="utf-8") + + assert source.count("_announce_self_evolution(console,") == 2 diff --git a/tests/test_self_management.py b/tests/test_self_management.py index dc74ebb2..470a699c 100644 --- a/tests/test_self_management.py +++ b/tests/test_self_management.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Comprehensive tests for the Phase 2.4 Self-Modification plugin. Covers: diff --git a/tests/test_semantic_adapter.py b/tests/test_semantic_adapter.py index e383afb3..3f58f5ee 100644 --- a/tests/test_semantic_adapter.py +++ b/tests/test_semantic_adapter.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """SemanticAdapter window-target and element_index addressing. Drives the real SemanticAdapter over the mock perception/execution adapters diff --git a/tests/test_semantic_schema.py b/tests/test_semantic_schema.py index 86280ac9..a69f1892 100644 --- a/tests/test_semantic_schema.py +++ b/tests/test_semantic_schema.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the semantic desktop tool schema layer and registration plugin.""" from __future__ import annotations diff --git a/tests/test_series_extractor.py b/tests/test_series_extractor.py index 7ec1dea5..5ee67a09 100644 --- a/tests/test_series_extractor.py +++ b/tests/test_series_extractor.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic tests for the anti-hallucination chart extractor. Pure text parsing: no network, no code execution, no invented numbers. diff --git a/tests/test_session_analysis.py b/tests/test_session_analysis.py index c3f9e5c0..20ff28be 100644 --- a/tests/test_session_analysis.py +++ b/tests/test_session_analysis.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic tests for the session-analysis dashboard (domain=session watch). Fakes the analysis services facade (no LLM); exercises producer gating, diff --git a/tests/test_session_factory.py b/tests/test_session_factory.py index 8b61403c..b26132b1 100644 --- a/tests/test_session_factory.py +++ b/tests/test_session_factory.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the Stage 3 per-session engine factory (P3-1). Proves the shallow-copy factory isolates the concurrency-corrupting substrate diff --git a/tests/test_session_registry.py b/tests/test_session_registry.py index a2c483db..f25b389e 100644 --- a/tests/test_session_registry.py +++ b/tests/test_session_registry.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the daemon SessionRegistry (Stage 3, P3-2a). Pure infrastructure tests with fake engine/working-memory factories: every diff --git a/tests/test_signal_buffer_overflow.py b/tests/test_signal_buffer_overflow.py index 0115bdea..521b3dd4 100644 --- a/tests/test_signal_buffer_overflow.py +++ b/tests/test_signal_buffer_overflow.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for SignalBuffer overflow observability (dropped_count tracking).""" from __future__ import annotations diff --git a/tests/test_signal_noise.py b/tests/test_signal_noise.py index 84e6d7ef..09978f26 100644 --- a/tests/test_signal_noise.py +++ b/tests/test_signal_noise.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Hermetic tests for monitor/display signal noise suppression.""" from __future__ import annotations diff --git a/tests/test_signal_source.py b/tests/test_signal_source.py index 1d081aee..f620cd36 100644 --- a/tests/test_signal_source.py +++ b/tests/test_signal_source.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the SignalSource protocol, built-in sources, and registry. Verifies that the pluginized extraction produces byte-for-byte identical diff --git a/tests/test_skill_lifecycle.py b/tests/test_skill_lifecycle.py index 4704a9d8..65cf50ad 100644 --- a/tests/test_skill_lifecycle.py +++ b/tests/test_skill_lifecycle.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scenario-based tests for the skill lifecycle. Covers registration, invocation, SkillDocument roundtrip, doc store CRUD, diff --git a/tests/test_slash_command_router.py b/tests/test_slash_command_router.py index 7fdb2387..9663b420 100644 --- a/tests/test_slash_command_router.py +++ b/tests/test_slash_command_router.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations import asyncio diff --git a/tests/test_teach_learn_lifecycle.py b/tests/test_teach_learn_lifecycle.py index c935a965..80a399d4 100644 --- a/tests/test_teach_learn_lifecycle.py +++ b/tests/test_teach_learn_lifecycle.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scenario-based integration tests for the learn-distill lifecycle.""" from __future__ import annotations diff --git a/tests/test_teacher_capability_validation.py b/tests/test_teacher_capability_validation.py index 3d28f834..c6a3ce7f 100644 --- a/tests/test_teacher_capability_validation.py +++ b/tests/test_teacher_capability_validation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """The teacher's capability names are validated, because a live model abused them. S9 ran `qwen3.7-plus` as the teacher against an episode that failed for a @@ -13,19 +14,30 @@ from __future__ import annotations +from leapflow.domain.evolution_intent import is_capability_name from leapflow.world_model.trajectory_grader import ( TrajectoryGrader, _echoes_goal, - _is_capability_name, ) # ── shape ───────────────────────────────────────────────────────────────────── + +def _verdict_intents(payload, goal=""): + """The acquisition intents a payload yields, through the real parser. + + Intents are derived from acquire verdicts now, so a test that wants to assert on + intents has to go through the same derivation production does. + """ + verdicts = _grader()._parse_verdicts(payload, goal) + return tuple(i for i in (v.to_intent() for v in verdicts) if i is not None) + + def test_real_capability_names_are_accepted(): for name in ("chat.reply", "ui.view_messages", "app.chat.send", "fs.file.read.bytes"): - assert _is_capability_name(name), name + assert is_capability_name(name), name def test_prose_and_bare_words_are_rejected(): @@ -42,7 +54,7 @@ def test_prose_and_bare_words_are_rejected(): "chat." + "x" * 90, # too long "/usr/bin/chat", # a path ): - assert not _is_capability_name(name), name + assert not is_capability_name(name), name # ── goal echo ───────────────────────────────────────────────────────────────── @@ -82,34 +94,33 @@ def __getattr__(self, name): def test_goal_restatement_never_becomes_a_requirement(): payload = { - "capability_gaps": [ - {"capability": "chat.cosmetic.example", "hypothesis": "the send failed"} + "adaptation_verdicts": [ + {"action": "acquire", "capability": "chat.cosmetic.example", "knowledge": "the send failed"} ] } - assert _grader()._parse_intents(payload, "chat.cosmetic.example") == () + assert _grader()._parse_verdicts(payload, "chat.cosmetic.example") == () def test_a_sentence_never_becomes_a_requirement(): payload = { - "capability_gaps": [ - {"capability": "the agent lacks a way to reply", "hypothesis": "h"} + "adaptation_verdicts": [ + {"action": "acquire", "capability": "the agent lacks a way to reply", "knowledge": "h"} ] } - assert _grader()._parse_intents(payload, "goal") == () + assert _grader()._parse_verdicts(payload, "goal") == () def test_a_well_formed_gap_still_passes(): payload = { - "capability_gaps": [ - { - "capability": "chat.reply", - "hypothesis": "the send control no-ops", + "adaptation_verdicts": [ + {"action": "acquire", "capability": "chat.reply", + "knowledge": "the send control no-ops", "confidence": 0.8, "expected_effect": "the reply appears in the thread", } ] } - intents = _grader()._parse_intents(payload, "reply to the latest message") + intents = _verdict_intents(payload, "reply to the latest message") assert len(intents) == 1 assert intents[0].capability == "chat.reply" assert intents[0].expected_effect == "the reply appears in the thread" @@ -117,12 +128,12 @@ def test_a_well_formed_gap_still_passes(): def test_one_bad_gap_does_not_discard_a_good_one(): payload = { - "capability_gaps": [ - {"capability": "my.goal", "hypothesis": "h"}, - {"capability": "chat.reply", "hypothesis": "the send control no-ops"}, + "adaptation_verdicts": [ + {"action": "acquire", "capability": "my.goal", "knowledge": "h"}, + {"action": "acquire", "capability": "chat.reply", "knowledge": "the send control no-ops"}, ] } - intents = _grader()._parse_intents(payload, "my.goal") + intents = _verdict_intents(payload, "my.goal") assert [i.capability for i in intents] == ["chat.reply"] @@ -131,16 +142,21 @@ def test_the_prompt_tells_the_model_both_rules(): from leapflow.world_model.trajectory_grader import _GAP_PROMPT_SECTION assert "Do NOT restate the task" in _GAP_PROMPT_SECTION - assert "return an empty list" in _GAP_PROMPT_SECTION + assert "Report nothing at all" in _GAP_PROMPT_SECTION assert "worse than" in _GAP_PROMPT_SECTION + # The action space replaced the binary question, so the prompt must also say what + # the cheap answers are -- otherwise "report nothing" is the only alternative to + # building something, and building wins by default. + assert "- absorb:" in _GAP_PROMPT_SECTION + assert "- rebind:" in _GAP_PROMPT_SECTION def test_model_authored_risk_is_still_clamped(): """The guard must not have disturbed the clamp: a model may never widen risk.""" payload = { - "capability_gaps": [ - {"capability": "chat.reply", "hypothesis": "h", "max_risk_level": "external"} + "adaptation_verdicts": [ + {"action": "acquire", "capability": "chat.reply", "knowledge": "h", "max_risk_level": "external"} ] } - intents = _grader()._parse_intents(payload, "goal") + intents = _verdict_intents(payload, "goal") assert intents[0].effective_risk_ceiling() == "read_only" diff --git a/tests/test_telegram_signal_source.py b/tests/test_telegram_signal_source.py index f75f7ab4..5ab35d6c 100644 --- a/tests/test_telegram_signal_source.py +++ b/tests/test_telegram_signal_source.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for TelegramBotSignalSource. Verifies ActiveSignalSource protocol conformance, fail-fast construction, diff --git a/tests/test_tool_call_hardening.py b/tests/test_tool_call_hardening.py index 32d8d8f1..24ff83b1 100644 --- a/tests/test_tool_call_hardening.py +++ b/tests/test_tool_call_hardening.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for P0 tool-calling / result hardening in the agent loop: - A1: pre-execution required-argument validation (_validate_tool_arguments) diff --git a/tests/test_tool_capability_declaration.py b/tests/test_tool_capability_declaration.py index 3640f244..1f7b3c21 100644 --- a/tests/test_tool_capability_declaration.py +++ b/tests/test_tool_capability_declaration.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unit tests for the declarative capability metadata on ToolMetadata. The resolver (built in a follow-up P1) needs two facts about a tool that today diff --git a/tests/test_tool_concurrency.py b/tests/test_tool_concurrency.py index 852d0323..776a846b 100644 --- a/tests/test_tool_concurrency.py +++ b/tests/test_tool_concurrency.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the metadata-driven tool concurrency policy (TC-P0). Parallel-safety is derived from registry ToolSpec metadata via diff --git a/tests/test_tool_handler_invocation.py b/tests/test_tool_handler_invocation.py index e626d004..dffcfd6c 100644 --- a/tests/test_tool_handler_invocation.py +++ b/tests/test_tool_handler_invocation.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for ToolMetadata handler invocation compatibility.""" from __future__ import annotations diff --git a/tests/test_tool_pipeline.py b/tests/test_tool_pipeline.py index 4b05231a..5bbd4f87 100644 --- a/tests/test_tool_pipeline.py +++ b/tests/test_tool_pipeline.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unit tests for the Waterfall Tool Execution Pipeline.""" from __future__ import annotations diff --git a/tests/test_tool_registry_conflict.py b/tests/test_tool_registry_conflict.py index 5523c1b4..34633043 100644 --- a/tests/test_tool_registry_conflict.py +++ b/tests/test_tool_registry_conflict.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Unit tests for tool-name conflict arbitration in ToolPluginRegistry. Tool names are a single global namespace consumed by the provider: two plugins diff --git a/tests/test_transport_discovery.py b/tests/test_transport_discovery.py index 86e03ac7..cbb09eb4 100644 --- a/tests/test_transport_discovery.py +++ b/tests/test_transport_discovery.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Entry-point discovery for out-of-tree transport kinds. Exercises ``_discover_entry_points()`` in isolation and through the public API, diff --git a/tests/test_trigger_policy.py b/tests/test_trigger_policy.py index 64b42f14..94b129e1 100644 --- a/tests/test_trigger_policy.py +++ b/tests/test_trigger_policy.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for TriggerPolicy — inbound message gating.""" from __future__ import annotations diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index 5d0aa1ba..77d31947 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations import asyncio diff --git a/tests/test_tui_session_summary.py b/tests/test_tui_session_summary.py index f2a4d918..dfbffe7b 100644 --- a/tests/test_tui_session_summary.py +++ b/tests/test_tui_session_summary.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations from dataclasses import dataclass diff --git a/tests/test_tui_theme.py b/tests/test_tui_theme.py index 3577bcf1..513ef00e 100644 --- a/tests/test_tui_theme.py +++ b/tests/test_tui_theme.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. from __future__ import annotations from os import terminal_size diff --git a/tests/test_tui_tool_audit.py b/tests/test_tui_tool_audit.py index f9fc9c9f..d339d815 100644 --- a/tests/test_tui_tool_audit.py +++ b/tests/test_tui_tool_audit.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for TUI tool-audit rendering fidelity. These pin the three defects observed in a real session: a parallel batch printed diff --git a/tests/test_turn_admission.py b/tests/test_turn_admission.py index 8539160d..90ee1986 100644 --- a/tests/test_turn_admission.py +++ b/tests/test_turn_admission.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for TurnAdmission bounded concurrency (Stage 3, P3-4).""" from __future__ import annotations diff --git a/tests/test_turn_admission_parking.py b/tests/test_turn_admission_parking.py index d1b1fca2..e423b1fb 100644 --- a/tests/test_turn_admission_parking.py +++ b/tests/test_turn_admission_parking.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Parking a turn's admission slot while it waits on a human decision. Approval prompts have no deadline, so a turn blocked on one must hand its slot diff --git a/tests/test_uncertain_effect_and_interaction.py b/tests/test_uncertain_effect_and_interaction.py index 90ffd563..a89fc48f 100644 --- a/tests/test_uncertain_effect_and_interaction.py +++ b/tests/test_uncertain_effect_and_interaction.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Guards for uncertain-effect reporting and InteractionRequest surfacing. Two contracts that only hold end-to-end: diff --git a/tests/test_unified_classifier.py b/tests/test_unified_classifier.py index 9cf4d524..4162cfbe 100644 --- a/tests/test_unified_classifier.py +++ b/tests/test_unified_classifier.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the UnifiedErrorClassifier. Covers: diff --git a/tests/test_visual_pipeline.py b/tests/test_visual_pipeline.py index 5219ba9a..0d89a5af 100644 --- a/tests/test_visual_pipeline.py +++ b/tests/test_visual_pipeline.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scenario-based tests for the video-first perception pipeline.""" from __future__ import annotations diff --git a/tests/test_web_fetch.py b/tests/test_web_fetch.py index 947d5144..1606e5ab 100644 --- a/tests/test_web_fetch.py +++ b/tests/test_web_fetch.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Tests for web_fetch: transport contract, egress gating, and extraction. Hermetic by construction: no test performs a real request. Transports are diff --git a/tests/test_workspace_escape_approval.py b/tests/test_workspace_escape_approval.py index 4eddd44d..4ae4b95b 100644 --- a/tests/test_workspace_escape_approval.py +++ b/tests/test_workspace_escape_approval.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Every path-oriented tool asks before crossing the workspace boundary. The refusal text has always said "Approval is required to access paths outside diff --git a/tests/test_world_model.py b/tests/test_world_model.py index 5e538299..d030db44 100644 --- a/tests/test_world_model.py +++ b/tests/test_world_model.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Scenario-based integration tests for the world model subsystem.""" from __future__ import annotations diff --git a/tests/test_world_model_driven_evolution_p1.py b/tests/test_world_model_driven_evolution_p1.py index 0a816836..8c792ae1 100644 --- a/tests/test_world_model_driven_evolution_p1.py +++ b/tests/test_world_model_driven_evolution_p1.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """P1: the world model becomes a driver of capability evolution. Two halves: @@ -83,9 +84,8 @@ def _grader(payload: str, *, grading_budget: int = 5): {"step": 2, "advantage": -0.9, "is_forking": False, "grade_label": "harmful"}, {"step": 3, "advantage": 0.2, "is_forking": False, "grade_label": "acceptable"}, ], - "capability_gaps": [{ - "capability": "chat.reply", - "hypothesis": "the send affordance was renamed and no adapter targets it", + "adaptation_verdicts": [{"action": "acquire", "capability": "chat.reply", + "knowledge": "the send affordance was renamed and no adapter targets it", "confidence": 0.82, "target_affordance": "app.chat.v2", "rationale": "every available tool binds send_button, which no longer exists", @@ -124,11 +124,11 @@ def test_proposing_costs_no_extra_budget_token(): def test_gap_section_only_appears_when_proposing(): grader, llm, _ = _grader(_GRADES) asyncio.run(grader.grade_trajectory(_TRAJECTORY)) - assert "capability_gaps" not in llm.prompts[0] + assert "adaptation_verdicts" not in llm.prompts[0] grader2, llm2, _ = _grader(_GRADES_AND_GAP) asyncio.run(grader2.grade_and_propose(_TRAJECTORY)) - assert "capability_gaps" in llm2.prompts[0] + assert "adaptation_verdicts" in llm2.prompts[0] def test_teacher_is_not_asked_to_choose_a_risk_level(): @@ -154,11 +154,11 @@ def test_malformed_gaps_are_discarded_without_losing_grades(): {"step": 2, "advantage": 0.2, "is_forking": False, "grade_label": "acceptable"}, {"step": 3, "advantage": 0.3, "is_forking": False, "grade_label": "acceptable"}, ], - "capability_gaps": [ - {"hypothesis": "no capability field"}, # missing capability - {"capability": "chat.reply"}, # missing hypothesis + "adaptation_verdicts": [ + {"knowledge": "no capability field"}, # missing capability + {"action": "acquire", "capability": "chat.reply"}, # missing hypothesis "not even an object", - {"capability": "chat.send", "hypothesis": "valid", "confidence": "NaN-ish"}, + {"action": "acquire", "capability": "chat.send", "knowledge": "valid", "confidence": "NaN-ish"}, ], }) grader, _, _ = _grader(payload) diff --git a/tests/test_world_model_driver.py b/tests/test_world_model_driver.py index 9500fb27..a840f8a0 100644 --- a/tests/test_world_model_driver.py +++ b/tests/test_world_model_driver.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """WM-B: the world model is now the first driver of capability evolution. `grade_and_propose` could form a capability hypothesis and the observation pipeline @@ -36,11 +37,36 @@ ] + +def _acquire_verdict(intent): + """Express an intent as the acquire verdict that would have produced it.""" + from leapflow.domain.adaptation_verdict import AdaptationVerdict + + return AdaptationVerdict.create( + "acquire", + intent.capability, + intent.hypothesis or f"nothing installed provides {intent.capability}", + rationale=intent.rationale or intent.hypothesis, + confidence=intent.confidence, + target_affordance=intent.target_affordance, + expected_effect=intent.expected_effect, + max_risk_level=intent.max_risk_level, + ) + + class _Teacher: """Stand-in for TrajectoryGrader with a fixed hindsight verdict.""" def __init__(self, intents=(), grades=("g1", "g2"), raises=False) -> None: - self._verdict = TeacherVerdict(tuple(grades), tuple(intents)) + # Intents are now *derived* from acquire verdicts rather than carried beside + # them, so a teacher stub expressing "I want this capability" says it the way + # the real teacher does: an acquire verdict, which the verdict object turns + # into the intent. Constructing intents directly would test a path production + # no longer has. + self._verdict = TeacherVerdict( + tuple(grades), + tuple(_acquire_verdict(intent) for intent in intents), + ) self._raises = raises self.calls = 0 @@ -207,9 +233,10 @@ def test_real_trajectory_grader_can_drive_evolution(tmp_path): {"step": 2, "advantage": -0.9, "is_forking": False, "grade_label": "harmful"}, {"step": 3, "advantage": -0.5, "is_forking": False, "grade_label": "suboptimal"}, ], - "capability_gaps": [{ + "adaptation_verdicts": [{ + "action": "acquire", "capability": "chat.reply", - "hypothesis": "the send control exists but no longer delivers the message", + "knowledge": "the send control exists but no longer delivers the message", "confidence": 0.77, "target_affordance": "app.chat.v2", "expected_effect": "the message appears in the thread", diff --git a/tests/test_world_model_evolution_p0.py b/tests/test_world_model_evolution_p0.py index 40da4366..9192cfd2 100644 --- a/tests/test_world_model_evolution_p0.py +++ b/tests/test_world_model_evolution_p0.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """World-model-first evolution foundation (P0). Covers the four P0 changes: diff --git a/tools/audit_inert_wiring.py b/tools/audit_inert_wiring.py new file mode 100644 index 00000000..dc981240 --- /dev/null +++ b/tools/audit_inert_wiring.py @@ -0,0 +1,183 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Audit for capability that is built but never runs. + +Three defects of this shape shipped with a green suite in one week: the lifecycle governor +was never constructed (`self.lifecycle_governor` read through `getattr(..., None)` and +assigned nowhere), the durable trust ledger was never handed to it (so one process held two +divergent views of trust and no plugin could earn PRODUCTION), and the hardware trust gate +was linked to a ledger that was always `None`. None of them raise. None of them fail a +test, because every unit test constructs the collaborator itself. The wiring is the one +thing a unit test cannot see. + +So this checks the two shapes those took, and -- importantly -- knows the three ways a +dependency can legitimately arrive, because the first two versions of this audit reported +wired code as inert by only knowing one of them: + +1. a keyword argument from another module +2. post-construction attribute assignment (``obj._dep = ...``) +3. ``setattr(obj, "_dep", ...)`` from a composer that must not import the type + +Known limitation: purely positional construction is not detected, so a finding still has +to be read before it is believed. That is why the output separates *shape* from *verdict*. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path("src/leapflow") + +#: Names that denote an injected collaborator rather than a value. A missing threshold +#: degrades a decision; a missing collaborator removes a feature. +SHAPES = ( + "sink", "store", "ledger", "provider", "tracker", "observer", "gate", "queue", + "actor", "coordinator", "verifier", "advisor", "factory", "extractor", "service", +) + +OPTIONAL_PARAM = re.compile(r"^\s{4,}([a-z_][a-z0-9_]*)\s*:\s*[^=]*=\s*None\s*,?\s*$", re.M) +SELF_GETATTR = re.compile(r'getattr\(\s*self\s*,\s*["\']([a-zA-Z_][a-zA-Z0-9_]*)["\']') + + +def _enclosing(text: str, offset: int) -> str: + head = text[:offset] + classes = list(re.finditer(r"^class\s+(\w+)", head, re.M)) + return classes[-1].group(1) if classes else "?" + + +def _supplied(name: str, owner: Path, repo: dict[Path, str]) -> tuple[int, str]: + """How many other modules supply this dependency, and by which channel.""" + channels = { + "keyword": re.compile(rf"(? list[tuple[int, str, str, str, str]]: + """Optional collaborators no other module supplies through any channel.""" + rows: list[tuple[int, str, str, str, str]] = [] + seen: set[tuple[str, str]] = set() + for path, text in repo.items(): + module = str(path).replace("src/leapflow/", "") + for match in OPTIONAL_PARAM.finditer(text): + name = match.group(1) + if not any(shape in name for shape in SHAPES): + continue + if (module, name) in seen: + continue + seen.add((module, name)) + count, channel = _supplied(name, path, repo) + rows.append((count, module, _enclosing(text, match.start()), name, channel)) + return sorted(rows, key=lambda r: (r[0], r[1])) + + +def audit_self_getattr(repo: dict[Path, str]) -> list[tuple[int, str, str]]: + """``getattr(self, "x", ...)`` where nothing anywhere ever sets ``x``.""" + rows: list[tuple[int, str, str]] = [] + seen: set[tuple[str, str]] = set() + for path, text in repo.items(): + module = str(path).replace("src/leapflow/", "") + for name in sorted(set(SELF_GETATTR.findall(text))): + if (module, name) in seen: + continue + seen.add((module, name)) + assigns = sum( + len(re.findall(rf"\.{re.escape(name)}\s*=(?!=)", other)) + + len(re.findall(rf'setattr\([^,]+,\s*["\']{re.escape(name)}["\']', other)) + for other in repo.values() + ) + rows.append((assigns, module, name)) + return sorted(rows, key=lambda r: (r[0], r[1])) + + +def audit_store_direction(repo: dict[Path, str]) -> list[tuple[str, int, int]]: + """Stores whose writes and reads do not both have production callers. + + ``ExperienceStore`` is the case this catches: the teacher's grades are written into it + and its only readers are in the hardware subsystem, so the distillation the docstring + promised terminated in a store nothing on the student's path consulted. A store written + and never read is data thrown away with extra steps; one read and never written is a + feature that can only ever return empty. + """ + rows: list[tuple[str, int, int]] = [] + for path, text in repo.items(): + if "storage/" not in str(path) and "_store.py" not in path.name: + continue + for match in re.finditer(r"^class\s+(\w*Store\w*)", text, re.M): + name = match.group(1) + if name.startswith("_"): + continue + body = text[match.start():] + writers = { + m.group(1) + for m in re.finditer( + r"def\s+((?:add|save|record|write|put|set|append|store|update|" + r"retract|remove|delete|resolve)\w*)", body + ) + } + readers = { + m.group(1) + for m in re.finditer( + r"def\s+((?:get|load|read|list|query|find|live|count|all|" + r"unresolved|recent|for_)\w*)", body + ) + } + outside = {p: t for p, t in repo.items() if p != path} + used = lambda names: sum( # noqa: E731 - local predicate, read once + 1 + for t in outside.values() + if any(re.search(rf"\.{re.escape(n)}\s*\(", t) for n in names) + ) + rows.append((f"{str(path).replace('src/leapflow/', '')}::{name}", + used(writers) if writers else -1, + used(readers) if readers else -1)) + return sorted(rows) + + +def main() -> None: + repo = {p: p.read_text(encoding="utf-8") for p in sorted(ROOT.rglob("*.py"))} + + print("=" * 100) + print("A1 getattr(self, \"x\") 且全仓无任何赋值 —— 功能静默不运行") + print("=" * 100) + a1 = [row for row in audit_self_getattr(repo) if row[0] == 0] + for _, module, name in a1: + print(f" ⚠ {module:<48} {name}") + print(f" 合计 {len(a1)} 处") + + print() + print("=" * 100) + print("A2 可选协作者,无任何模块通过 keyword/attribute/setattr 供给") + print("=" * 100) + rows = audit_optional_injections(repo) + inert = [row for row in rows if row[0] == 0] + for _, module, cls, name, _channel in inert: + print(f" ⚠ {module:<40} {cls:<28} {name}") + print(f" 合计 {len(inert)} 处(共检查 {len(rows)} 个可选注入点)") + + print() + print("=" * 100) + print("A3 存储的写侧或读侧在生产中无调用方 —— 数据白写,或功能恒空") + print("=" * 100) + oneway = 0 + for name, writers, readers in audit_store_direction(repo): + if writers == 0 or readers == 0: + oneway += 1 + missing = "写侧无调用方" if writers == 0 else "读侧无调用方" + print(f" ⚠ {name:<62} {missing}") + print(f" 合计 {oneway} 处") + + +if __name__ == "__main__": + main() diff --git a/tools/impact.py b/tools/impact.py index 237aee1d..6257e402 100644 --- a/tools/impact.py +++ b/tools/impact.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Change-scoped test selection. Two separate jobs, with different economics: diff --git a/tools/sync_fixtures.py b/tools/sync_fixtures.py index 49662b15..73b6dcc7 100644 --- a/tools/sync_fixtures.py +++ b/tools/sync_fixtures.py @@ -1,3 +1,4 @@ +# Copyright (c) Alibaba, Inc. and its affiliates. """Derive mock-layer LLM fixtures from recorded cassettes. This is the join between the two test layers. The mock layer keeps its speed and From cc03502bbd4cf536560ff9c5c0e7b2334a702eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 15 Sep 2026 15:59:53 +0800 Subject: [PATCH 5/6] add news --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2d73adc8..8e83e073 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,13 @@ ### News +- **2026-09-15**: v0.2.0 released — teacher/student world-model evolution: a cold-path teacher grades every session and distills what it learned about the environment into the student's next-turn context via four-value adaptation verdicts (absorb / rebind / acquire / escalate), with supersede/expire/retract knowledge retirement and a rebind-preference selection signal; capability acquisition (self-evolution) is a default-off, prominently surfaced `evolution.enabled` switch. 3,733 tests. - **2026-08-12**: v0.0.9 released — TUI thinking display (LLM reasoning surfaced in-place with spinner preview + final panel), approval bypass mode (`approval_bypass` config + session-wide "Allow ALL"), workspace boundary softened to approval-gated, long-task convergence hardening (false-progress fix, repeated-read gate, periodic checkpoint forcing, pre-compression knowledge extraction), cross-session task history (automatic session summaries + proactive history injection), dynamic tool registry rebuild for late-registered tools, terminal sessions enabled by default. -- **2026-08-06**: v0.0.8 released — Cross-platform Windows support (DaemonTransport protocol with TCP loopback IPC), real journey test layer with cassette-backed CI (6 e2e journeys, cost-bounded), community Windows fixes (@fanqiNO1). 1,540 tests.
Previous releases +- **2026-08-06**: v0.0.8 released — Cross-platform Windows support (DaemonTransport protocol with TCP loopback IPC), real journey test layer with cassette-backed CI (6 e2e journeys, cost-bounded), community Windows fixes (@fanqiNO1). 1,540 tests. - **2026-08-06**: v0.0.7 released — 1M-class context windows end-to-end, self-calibrating token estimator, internal-defect failure category, concurrent-TUI session identity isolation. 1,442 tests. - **2026-07-31**: v0.0.6 released — side-effect-gated recovery (checkpointed halts with structured `InteractionRequest`), uncertain-effect reporting for failed outbound calls, centralized logging with an independent daemon log level, session-bound LeapBoard analysis, platform-neutral gateway validators, and end-to-end architecture contract tests with the CI gate restored. From 2af5ea2bd198b6936ae2b9d1705c243832b2cfc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 15 Sep 2026 16:08:39 +0800 Subject: [PATCH 6/6] update news --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8e83e073..8a0be33c 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ ### News -- **2026-09-15**: v0.2.0 released — teacher/student world-model evolution: a cold-path teacher grades every session and distills what it learned about the environment into the student's next-turn context via four-value adaptation verdicts (absorb / rebind / acquire / escalate), with supersede/expire/retract knowledge retirement and a rebind-preference selection signal; capability acquisition (self-evolution) is a default-off, prominently surfaced `evolution.enabled` switch. 3,733 tests. -- **2026-08-12**: v0.0.9 released — TUI thinking display (LLM reasoning surfaced in-place with spinner preview + final panel), approval bypass mode (`approval_bypass` config + session-wide "Allow ALL"), workspace boundary softened to approval-gated, long-task convergence hardening (false-progress fix, repeated-read gate, periodic checkpoint forcing, pre-compression knowledge extraction), cross-session task history (automatic session summaries + proactive history injection), dynamic tool registry rebuild for late-registered tools, terminal sessions enabled by default. +- **2026-09-15**: v0.2.0 released — teacher/student world-model self-evolution: hindsight adaptation verdicts (absorb / rebind / acquire / escalate) distill environment knowledge into the student's context, rebind-based selection preference, and a default-off `evolution.enabled` switch. 3,733 tests. +- **2026-08-12**: v0.0.9 released — TUI thinking display, approval bypass mode, approval-gated workspace boundary, long-task convergence hardening, cross-session task history, dynamic tool registry rebuild, and terminal sessions on by default.
Previous releases