fix(R9): trust hierarchy for project attribution

Batch 3, Days 1-3. The core R9 failure was Case F: when the model
returned a registered project DIFFERENT from the interaction's
known scope, the old code trusted the model because the project
was registered. A p06-polisher interaction could silently produce
a p04-gigabit candidate.

New rule (trust hierarchy):
1. Interaction scope always wins when set (cases A, C, E, F)
2. Model project used only for unscoped interactions AND only when
   it resolves to a registered project (cases D, G)
3. Empty string when both are empty or unregistered (case B)

The rule is: interaction.project is the strongest signal because
it comes from the capture hook's project detection, which runs
before the LLM ever sees the content. The model's project guess
is only useful when the capture hook had no project context.

7 case tests (A-G) cover every combination of model/interaction
project state. Pre-existing tests updated for the new behavior.

Host-side script mirrors the same hierarchy using _known_projects
fetched from GET /projects at startup.

Test count: 286 -> 290 (+4 net, 7 new R9 cases, 3 old tests
consolidated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 15:37:29 -04:00
parent 144dbbd700
commit e5e9a9931e
3 changed files with 86 additions and 38 deletions

View File

@@ -254,16 +254,15 @@ def _parse_candidates(raw_output: str, interaction: Interaction) -> list[MemoryC
continue
mem_type = str(item.get("type") or "").strip().lower()
content = str(item.get("content") or "").strip()
project = str(item.get("project") or "").strip()
if not project and interaction.project:
model_project = str(item.get("project") or "").strip()
# R9 trust hierarchy for project attribution:
# 1. Interaction scope always wins when set (strongest signal)
# 2. Model project used only when interaction is unscoped
# AND model project resolves to a registered project
# 3. Empty string when both are empty/unregistered
if interaction.project:
project = interaction.project
elif project and interaction.project and project != interaction.project:
# R9: model returned a different project than the interaction's
# known scope. Trust the model's project only if it resolves
# to a known registered project (the registry normalizes
# aliases and returns the canonical id). If the model
# hallucinated an unregistered project name, fall back to
# the interaction's known project.
elif model_project:
try:
from atocore.projects.registry import (
load_project_registry,
@@ -271,13 +270,12 @@ def _parse_candidates(raw_output: str, interaction: Interaction) -> list[MemoryC
)
registered_ids = {p.project_id for p in load_project_registry()}
resolved = resolve_project_name(project)
if resolved not in registered_ids:
project = interaction.project
else:
project = resolved
resolved = resolve_project_name(model_project)
project = resolved if resolved in registered_ids else ""
except Exception:
project = interaction.project
project = ""
else:
project = ""
confidence_raw = item.get("confidence", 0.5)
if mem_type not in MEMORY_TYPES:
continue