portfolio-optimiser/src/portfolio_optimiser/persona.py
Kjell Tore Guttormsen 488a0f2b6c feat(persona): load the falsification skill from commons at call time
[skip-docs] — the invariant row for this plan lands in Step 13, after the mutations.

Amendment 2 in the same commit: the framework-neutrality sweep covered only
expert-reviewer, so the skill that arrived by subtree pull had no framework guard
anywhere. GUARDED_SKILL_DIRS names both, and a fail-closed coverage arm turns red
when a future pull brings a third skill that is not listed.

Co-Authored-By: Claude <claude-opus-5>
2026-09-02 21:31:32 +02:00

140 lines
5.9 KiB
Python

"""Loader for the shared expert-reviewer persona artifact (målbilde §8).
The persona lives in ``shared/skills/expert-reviewer/`` as a framework-neutral Agent Skill (SKILL.md
+ a canonical example verdict). ``shared/`` stays pure DATA — this loader is the MAF-side reader of
it; the Claude-SDK sibling reads the same JSON with its own loader. This is what de-stubs the
offline simulation: its persona judgement is sourced from the artifact instead of a hardcoded
literal, so the shared persona is genuinely consumed (and cannot rot silently).
Fail-fast on purpose: the example is REQUIRED input (contrast the tolerant async verdict inbox,
``verdicts.load_verdicts_from_dir``). A missing or malformed file raises rather than degrading —
a broken shared artifact must surface, not pass silently.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from portfolio_optimiser.shared_root import shared_root
# Test seam: when set (monkeypatched), wins over the resolver. Read at CALL time inside the loader
# (never frozen into a default argument), which is what makes the simulation's persona genuinely
# artifact-driven.
_EXAMPLE_PATH: Path | None = None
_EXAMPLE_SUBPATH = Path("skills") / "expert-reviewer" / "references" / "example-verdict.json"
def _example_path() -> Path:
"""The persona example's location: the test seam if set, else resolved under ``shared_root()``
(env ``PORTFOLIO_SHARED_ROOT`` re-points it — the S4 extraction seam)."""
return _EXAMPLE_PATH if _EXAMPLE_PATH is not None else shared_root() / _EXAMPLE_SUBPATH
@dataclass(frozen=True)
class PersonaExample:
"""The expert-reviewer persona's canonical example verdict: the judgement the persona renders for
the reference measure. ``marker`` is a substring of ``rationale`` (the realization-rate payload
the simulation traces across runs)."""
decision: str
rationale: str
marker: str
def load_persona_example() -> PersonaExample:
"""Read the persona's canonical example verdict. Fail-fast: a missing file raises
``FileNotFoundError`` and a missing key raises ``KeyError`` (required input). Resolves the
path at call time (``_example_path``) so both the test seam and the env override are live."""
data = json.loads(_example_path().read_text(encoding="utf-8"))
return PersonaExample(
decision=data["decision"],
rationale=data["rationale"],
marker=data["marker"],
)
# --- The falsification-reviewer skill (Step 12, D4) ----------------------------------------------
# A SECOND shared Agent Skill, read the same way and for the same reason: it is authored in commons
# and arrives by ``git subtree pull``, so a loader that consumes it is what keeps it from rotting
# silently. Same call-time seam, same fail-fast discipline as the persona example above.
_FALSIFICATION_EXAMPLE_PATH: Path | None = None
_FALSIFICATION_SUBPATH = (
Path("skills") / "falsification-reviewer" / "references" / "example-evidence.json"
)
def _falsification_example_path() -> Path:
"""The worked example's location: the test seam if set, else resolved under ``shared_root()``."""
if _FALSIFICATION_EXAMPLE_PATH is not None:
return _FALSIFICATION_EXAMPLE_PATH
return shared_root() / _FALSIFICATION_SUBPATH
@dataclass(frozen=True)
class FalsificationConcept:
"""One concept the worked example judged, with what the readers should derive from its bytes.
``frontmatter_verbatim`` is AUTHORITATIVE and is the only field a consumer should materialise
from. The example also ships a line-oriented ``frontmatter`` projection, which is informative
only: by construction it cannot carry a block form, so materialising from it would turn the
unreadable case into an absent one — the tolerant read that widens the answer.
"""
concept_id: str
#: The document's frontmatter block exactly as written, newlines and indentation included.
frontmatter_verbatim: str
state: str
reason: str | None
items_seen: int
trust_tier: str
adjudication: str
#: Whether the verdict was allowed to rest on this concept.
relied_on: bool
@dataclass(frozen=True)
class FalsificationExample:
"""The falsification reviewer's worked example: one claim, the concepts consulted, the verdict.
``judgement`` is ``undecided`` in the shipped example, and deliberately not ``survived``: the
one concept that could have carried a refutation was unreadable, so the claim was never
actually attacked. Returning ``survived`` there would convert a gap in the knowledge base into
support for the claim — the inversion this role exists to prevent.
"""
claim: str
judgement: str
#: Who refuted the claim, or ``None`` when nobody did. A refutation that names no refuter is
#: not a refutation (the ``verified``-without-an-actor rule, one layer up).
refuter: str | None
concepts: tuple[FalsificationConcept, ...]
def load_falsification_example() -> FalsificationExample:
"""Read the falsification skill's worked example. Fail-fast: a missing file raises
``FileNotFoundError`` and a missing key raises ``KeyError`` (required input, contrast the
tolerant Step-7 verdict inbox). Resolves the path at CALL time."""
data = json.loads(_falsification_example_path().read_text(encoding="utf-8"))
return FalsificationExample(
claim=data["claim"],
judgement=data["judgement"],
refuter=data["refuter"],
concepts=tuple(
FalsificationConcept(
concept_id=c["concept_id"],
frontmatter_verbatim=c["frontmatter_verbatim"],
state=c["evidence"]["state"],
reason=c["evidence"]["reason"],
items_seen=c["evidence"]["items_seen"],
trust_tier=c["derived"]["trust_tier"],
adjudication=c["derived"]["adjudication"],
relied_on=c["relied_on"],
)
for c in data["concepts"]
),
)