diff --git a/examples/control_plane/todo-user-gate-scope-smoke.py b/examples/control_plane/todo-user-gate-scope-smoke.py index d96a7dde8d..237fb4c1ea 100644 --- a/examples/control_plane/todo-user-gate-scope-smoke.py +++ b/examples/control_plane/todo-user-gate-scope-smoke.py @@ -179,39 +179,16 @@ def read_todo(registry: Path, todo_id: str) -> dict: return view["todo"] -def assert_legacy_scope_diagnostics() -> None: - # Historical source validation remains readable before import. It cannot - # serve as a normal writer or alter an already canonical authority. - with tempfile.TemporaryDirectory(prefix="loopx-legacy-gate-diagnostics-") as tmp: +def assert_unpromoted_source_is_not_contract_authority() -> None: + # Detailed metadata diagnostics use canonical rows; the File/SQLite cases + # in test_canonical_status_todos cover them without a display file. + with tempfile.TemporaryDirectory(prefix="loopx-unpromoted-gate-diagnostics-") as tmp: root = Path(tmp) repo, state, registry = write_fixture(root, import_authority=False) insert_before_agent_section( state, "- [ ] Approve an intentionally unscoped gate.\n" - " \n" - "- [ ] Track an unbound non-blocking user action.\n" - " \n" - "- [ ] Resolve a mismatched gate binding.\n" - f" \n" - "- [ ] Track an intentionally untyped user todo.\n" - " \n" - "- [ ] Approve a gate with an unregistered blocked agent.\n" - " \n" - "- [ ] Approve a gate with conflicting scope.\n" - f" ", - ) - state.write_text( - state.read_text(encoding="utf-8") - + "\n- [ ] Repair a removed continuation value.\n" - + " \n" - + "- [ ] Repair removed agent gate routing.\n" - + f" \n" - + "- [ ] Repair an unknown executor exclusion.\n" - + " \n" - + "- [ ] Repair malformed executor exclusion metadata.\n" - + " \n", - encoding="utf-8", + " ", ) original = state.read_bytes() check = check_contract( @@ -219,19 +196,8 @@ def assert_legacy_scope_diagnostics() -> None: scan_roots=[repo], limit=1, ) assert check["ok"] is False, check - for todo_id, diagnostic in ( - ("todo_unscoped_gate", "requires bound_agent"), - ("todo_unbound_action", "requires bound_agent"), - ("todo_mismatched_gate_binding", "must bind to blocks_agent"), - ("todo_untyped_user", "requires task_class"), - ("todo_bad_agent", "not registered"), - ("todo_both_scopes", "cannot set both"), - ("todo_removed_continuation", "removed continuation_policy"), - ("todo_removed_agent_gate", "removed blocks_agent routing"), - ("todo_unknown_exclusion", "unregistered agents"), - ("todo_malformed_exclusion", "malformed excluded_agents"), - ): - assert any(todo_id in error and diagnostic in error for error in check["errors"]), check + assert any(row["code"] == "canonical_authority_required" + and row.get("goal_id") == GOAL_ID for row in check["error_diagnostics"]), check assert state.read_bytes() == original @@ -673,7 +639,7 @@ def main() -> int: assert state.read_bytes() != original state.write_bytes(original) - assert_legacy_scope_diagnostics() + assert_unpromoted_source_is_not_contract_authority() assert_imported_scope_repairs() print("todo-user-gate-scope-smoke ok") diff --git a/examples/explore-configure-goal-smoke.py b/examples/explore-configure-goal-smoke.py index 598c66cd34..785ceacf2b 100644 --- a/examples/explore-configure-goal-smoke.py +++ b/examples/explore-configure-goal-smoke.py @@ -11,6 +11,9 @@ REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 + GOAL_ID = "explore-configure-fixture" @@ -86,7 +89,9 @@ def write_fixture(root: Path) -> tuple[Path, Path]: + "\n", encoding="utf-8", ) - return registry, root / "runtime" + runtime_root = root / "runtime" + import_fixture_authority(registry, GOAL_ID) + return registry, runtime_root def goal(registry: Path) -> dict: diff --git a/examples/explore-worker-plan-gate-smoke.py b/examples/explore-worker-plan-gate-smoke.py index 7b60704b61..6badf4fd58 100644 --- a/examples/explore-worker-plan-gate-smoke.py +++ b/examples/explore-worker-plan-gate-smoke.py @@ -37,6 +37,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) +from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 from loopx.capabilities.explore.todo_branch_plan import ( # noqa: E402 MAX_BRANCH_WIDTH, @@ -387,6 +388,8 @@ def check_cli_gate_states() -> None: + "\n", encoding="utf-8", ) + for entry in goal_entries: + import_fixture_authority(registry, entry["id"]) def run_cli(*extra_args: str, fmt: str = "json", check: bool = True): result = subprocess.run( diff --git a/loopx/capabilities/explore/README.md b/loopx/capabilities/explore/README.md index 0c898d43ae..96767a41fa 100644 --- a/loopx/capabilities/explore/README.md +++ b/loopx/capabilities/explore/README.md @@ -74,6 +74,13 @@ identify the current retained candidate, establish causality, satisfy acceptance or authorize execution. Actual adoption and outcome benefit require subsequent work and evidence; automatic branch rankings remain unchanged. +Manually captured measured findings also appear in the existing result pages +and, when linked to eligible work, its exact-source comparison cards. They need +no `writeback-result` origin tag. Read the page before optionally binding a +`selected_candidate`; normal source-revision, Todo-link and followthrough checks +still apply. Unmeasured manual notes retain their existing summary/audit path. +Reading or selecting a measured result does not certify adoption or execution. + ## At a Glance Explore Harness is a supported, default-off optional capability for diff --git a/loopx/capabilities/explore/README.zh-CN.md b/loopx/capabilities/explore/README.zh-CN.md index 44c24085ec..07b2b51dd5 100644 --- a/loopx/capabilities/explore/README.zh-CN.md +++ b/loopx/capabilities/explore/README.zh-CN.md @@ -60,6 +60,11 @@ 重复尝试。best 不代表当前保留版本、因果收益、验收通过或执行授权;是否实际采用 并产生收益,仍需后续工作和证据确认。自动分支排序保持原有行为。 +手动登记的量化 finding 同样进入既有结果分页;关联可执行工作时,也进入其精确来源 +比较卡片,无需补写 `writeback-result` 来源标签。先读取结果页,再按需绑定 +`selected_candidate`,仍核对来源修订、Todo 关联和后续实际工作。未带测量的手动笔记 +保持既有摘要/审计路径。读取或选择某条成绩不认证实际采用或执行。 + ## 概览 LoopX Explore 是面向长程探索目标(软件研究、安全攻击面测绘、领域调研)的受支持、默认关闭的可选能力。它把“到处看看”变成有界、可观测、有门控的流程,包含三根支柱: diff --git a/loopx/control_plane/capabilities/explore_turn_context.ts b/loopx/control_plane/capabilities/explore_turn_context.ts index 04097d813b..99cd225397 100644 --- a/loopx/control_plane/capabilities/explore_turn_context.ts +++ b/loopx/control_plane/capabilities/explore_turn_context.ts @@ -112,16 +112,20 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject { const audit = requireJsonObject(branch.typed_evidence_audit, "Explore evidence audit"); return requireStringArray(audit.requested_node_refs ?? [], "Explore node refs"); }) : []); - const attachedResults = findings.filter(row => Array.isArray(row.tags) && row.tags.includes("writeback-result")); + // Manual quantitative capture uses the same finding owner. Keep its sources + // available to the existing exact-result decision path, without requiring a + // writeback-origin tag or admitting unrelated unmeasured notes. + const decisionResults = findings.filter(row => row.measurements !== undefined + || Array.isArray(row.tags) && row.tags.includes("writeback-result")); // Keep the same detail budget, but do not let unrelated newer results evict // the applicability of evidence linked to the next selected work item. // Explicitly bound work can differ from this independent planner. Prioritize // its canonical links without changing candidates, scores or admission. - const plannerResults = prioritizeLinkedResults(attachedResults, selectedRefs); + const plannerResults = prioritizeLinkedResults(decisionResults, selectedRefs); // Shared links must not inherit another task's planner priority. Use source // recency within the focus and retain planner ordering only outside it. const writebackResults = focusedRefs.size - ? prioritizeLinkedResults(attachedResults, focusedRefs, plannerResults) : plannerResults; + ? prioritizeLinkedResults(decisionResults, focusedRefs, plannerResults) : plannerResults; const command = (...args: string[]) => [...route, "explore", ...args, "--goal-id", goal]; const resultLimit = requireInteger(params.result_limit ?? 3, "result_limit"); const resultOffset = requireInteger(params.result_offset ?? 0, "result_offset"); diff --git a/tests/control_plane/test_stride_comparison_followthrough_cli.py b/tests/control_plane/test_stride_comparison_followthrough_cli.py index 9b3c50df88..68e33604b7 100644 --- a/tests/control_plane/test_stride_comparison_followthrough_cli.py +++ b/tests/control_plane/test_stride_comparison_followthrough_cli.py @@ -150,7 +150,8 @@ def review(value, turn_id=turn, *, expected=0): @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_wide_result_page_replays_every_linked_outcome_at_writeback(tmp_path, monkeypatch, provider): +@pytest.mark.parametrize("tagged", [False, True], ids=["manual-measurement", "writeback-result"]) +def test_wide_result_page_replays_every_linked_outcome_at_writeback(tmp_path, monkeypatch, provider, tagged): isolate_sqlite_runtime(tmp_path, monkeypatch) monkeypatch.setenv("LOOPX_USAGE_PING", "0") monkeypatch.setenv("PYTHONPATH", str(fixture.REPO_ROOT)) @@ -175,7 +176,7 @@ def cli(*args, expected=0): actor.probe(request, "retained") for index in range(4): measurements = project / f"measurement-{index}.json" - measurements.write_text(json.dumps([{"metric": "quality", "value": index, + measurements.write_text(json.dumps([{"metric": "quality", "value": 3 - index, "unit": "points", "higher_is_better": True, "comparison_scope": "validation:owned-v1", "input_revision": "owned-input-v1", "evaluation_ref": "evaluator:owned-v1", "candidate_ref": f"candidate:scope-{index}", @@ -183,7 +184,8 @@ def cli(*args, expected=0): cli("explore", "finding", *identity, "--finding-id", f"wide-result-{index}", "--node", "comparison-retained", "--title", f"Observed input scope {index}", "--summary", f"Only the recorded input scope {index}; other inputs remain unknown.", - "--status", "confirmed", "--tag", "writeback-result", "--evidence-ref", f"measured:scope-{index}", + "--status", "confirmed", *(("--tag", "writeback-result") if tagged else ()), + "--evidence-ref", f"measured:scope-{index}", "--measurements-json", str(measurements)) turn = "wide-page-review" cli("quota", "should-run", *identity, "--codex-app", "--turn-instance-id", turn) @@ -196,17 +198,20 @@ def cli(*args, expected=0): # Choose a quantified outcome omitted by the default three-row view. visible = {row["finding_id"]: row for row in page["graph"]["writeback_results"]} default_refs = {row["finding_id"] for row in control["explore_basis"]["comparison_candidates"][0]["outcomes"]} - selected = {"todo_id": fixture.TODO_ID, **next(row for row in reversed(card["outcomes"]) - if row["finding_id"] not in default_refs and visible[row["finding_id"]].get("measurements"))} - measurement = visible[selected["finding_id"]]["measurements"][0] - assert measurement["evidence_ref"] == visible[selected["finding_id"]]["evidence_refs"][0] [feedback] = page["graph"]["measurement_feedback"] assert feedback["measured_count"] == 4 - assert feedback["best"]["candidate_ref"] == "candidate:scope-3" + assert feedback["best"]["candidate_ref"] == "candidate:scope-0" + assert feedback["best"]["value"] == 3 and feedback["latest"]["measurement"]["value"] == 0 + selected = {"todo_id": fixture.TODO_ID, **next(row for row in card["outcomes"] + if row["finding_id"] == feedback["best"]["finding_id"])} + assert selected["finding_id"] not in default_refs, "an older best must remain selectable beyond the first page" + measurement = visible[selected["finding_id"]]["measurements"][0] + assert measurement["evidence_ref"] == visible[selected["finding_id"]]["evidence_refs"][0] decision = deepcopy(control["minimal_example"]) decision["explore_review"] = {"revision": control["explore_basis"]["revision"], "disposition": "used", "considered_refs": [selected["finding_id"]], - "selected_candidate": selected, "reason": "This exact measured scope informs the next experiment.", + "selected_candidate": selected, + "reason": "Revisit the same-scope best quality of 3 before repeating the latest candidate's score of 0.", "result_pages": [{k: page["graph"]["result_page"][k] for k in ("revision", "offset", "limit", "todo_id")}]} packet = actor.vision(request, selected["finding_id"], outcome="replan") packet.update(stride_decision=decision, todo_delta=["retain:" + fixture.TODO_ID]) diff --git a/tests/control_plane_ts/explore_turn_context.test.ts b/tests/control_plane_ts/explore_turn_context.test.ts index f49f500ed3..1b2904a547 100644 --- a/tests/control_plane_ts/explore_turn_context.test.ts +++ b/tests/control_plane_ts/explore_turn_context.test.ts @@ -82,11 +82,11 @@ for (const includeDigests of [false, true]) { }); } -for (const limit of [3, 4, 20]) { - test(`every scoped outcome on an explicitly requested page remains selectable: ${limit}`, () => { +for (const tagged of [false, true]) for (const limit of [3, 4, 20]) { + test(`every scoped measured outcome remains selectable: tagged=${tagged}, limit=${limit}`, () => { const findings = Array.from({length: 20}, (_, index) => ({finding_id: `scoped-${index}`, node_id: "question", finding: `Measured input family ${index}`, status: "confirmed", - summary: `Applicable to input family ${index} only.`, tags: ["writeback-result"], + summary: `Applicable to input family ${index} only.`, tags: tagged ? ["writeback-result"] : [], evidence_refs: [`result:scoped-${index}`], measurements: [{metric: "quality", value: index, unit: "points", higher_is_better: true, comparison_scope: "validation:owned-v1", input_revision: "owned-input-v1", @@ -99,6 +99,7 @@ for (const limit of [3, 4, 20]) { typed_evidence_audit: {requested_node_refs: ["question"]}}]}}; const source = projectExploreTurnContext(request); const graph = source.graph as JsonObject, harness = source.harness as JsonObject; + assert.ok(harness.comparison_candidates?.length, "measured findings must reach the existing decision cards without an origin tag"); const card = harness.comparison_candidates[0]; // Quantitative feedback still uses all sources, independently of page size. assert.equal(graph.measurement_feedback[0].measured_count, 20);