feat(s53): --portfolio mode + --goals/--ledger wiring, GoalReached observable in CLI output

main() gains a --portfolio mode flag dispatching to the EXISTING run_portfolio (not modified):
loads --goals/--ledger/--dimension-config via the fail-fast loaders, passes project_ids=(pid,)
or None (all reference projects), and prints a deterministic goal-stop line
'goal reached: scope=... project=... observed_ore=... limit_ore=... stopped_early=...' from the
returned GoalReached/PortfolioResult. Positional project_id relaxed to nargs='?' and --docs-dir to
optional, with a compensating single-project-mode guard (no pid/no --docs-dir -> rc 1 refusal) so
the legacy contract still fails loudly. Dispatch + guard sit BEFORE the live_dry_run branch
(Pass-2 #3 — appended after, they'd be dead code). Loader failures use the same structured refusal
('portfolio run refused: ...'). Load-bearing pair (RED-first): portfolio-hard goal already met
breaks offline before any client (scope=portfolio, stopped_early=True); per-project-hard control
skips the only pid (scope=project, stopped_early=False) — the two arms force the printed fields to
derive from run_portfolio's real values. Marker 13731 øre. 9 passed, ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
This commit is contained in:
Kjell Tore Guttormsen 2026-07-23 21:40:35 +02:00
commit 905b2f9a43
2 changed files with 154 additions and 3 deletions

View file

@ -35,7 +35,7 @@ from pydantic import ValidationError
from portfolio_optimiser.backends import Profile, get_backend, resolve_model
from portfolio_optimiser.budget import Budget, BudgetMiddleware, TokenMeter
from portfolio_optimiser.contracts import GoalConfig, GoalContract, load_contracts
from portfolio_optimiser.contracts import GoalConfig, GoalContract, load_contracts, load_goal_config
from portfolio_optimiser.ledger import SavingsLedger
from portfolio_optimiser.datasource import (
bundle_citations,
@ -614,9 +614,12 @@ def main(argv: list[str] | None = None) -> int:
import sys
parser = argparse.ArgumentParser(description="portfolio-optimiser vertical slice")
parser.add_argument("project_id")
# project_id + --docs-dir are relaxed from required to a mode-conditional refusal (below): the
# single-project path still requires both, but portfolio mode takes neither. The compensating
# guard keeps the legacy contract failing loudly (rc 1 refusal) instead of via argparse exit 2.
parser.add_argument("project_id", nargs="?", default=None)
parser.add_argument("--profile", default="local")
parser.add_argument("--docs-dir", required=True)
parser.add_argument("--docs-dir", default=None)
parser.add_argument(
"--bundle-dir", default=None, help="OKF bundle dir (enables the Step-1 fold)"
)
@ -646,6 +649,25 @@ def main(argv: list[str] | None = None) -> int:
help="stable run id for --outbox-dir artefacts (required when --outbox-dir is set; no "
"wall-clock/uuid default — the outbox artefacts are byte-deterministic)",
)
parser.add_argument(
"--portfolio",
action="store_true",
help="portfolio mode: dispatch to run_portfolio over all reference projects (or the single "
"given PROJECT_ID). Takes --goals/--ledger/--dimension-config; the single-project-only flags "
"are refused in this mode (the two CLI modes are a documented partition)",
)
parser.add_argument(
"--goals",
default=None,
help="portfolio mode: goal config JSON (fail-fast) — the GoalReached stop is checked against "
"the ledger before each project",
)
parser.add_argument(
"--ledger",
default=None,
help="portfolio mode: accumulated savings ledger JSON (fail-fast) read for the goal-stop "
"(earlier out-of-band HITL realizations — never built during the pass)",
)
parser.add_argument("--decision", default="approved", choices=["approved", "rejected"])
parser.add_argument("--rationale", default="reviewed by expert")
parser.add_argument(
@ -655,6 +677,48 @@ def main(argv: list[str] | None = None) -> int:
)
args = parser.parse_args(argv)
if args.portfolio:
# Portfolio mode (Step 3): dispatch to the EXISTING run_portfolio via the fail-fast loaders
# (run_portfolio itself is unchanged). Loader/ValueError failures surface through the same
# structured-refusal contract as the single-project path (stderr + rc 1, no traceback).
try:
goals = load_goal_config(args.goals) if args.goals else None
ledger = SavingsLedger.load(args.ledger) if args.ledger else None
dimension = load_dimension(args.dimension_config) if args.dimension_config else None
project_ids = (args.project_id,) if args.project_id is not None else None
portfolio_result = asyncio.run(
run_portfolio(
project_ids,
args.profile,
dimension=dimension,
ledger=ledger,
goals=goals,
)
)
except (ValueError, FileNotFoundError, ValidationError) as exc:
print(f"portfolio run refused: {exc}", file=sys.stderr)
return 1
for r in portfolio_result.runs:
print(f"{type(r.outcome).__name__}: verdict id={r.verdict.id}")
if portfolio_result.stop_reason is not None:
sr = portfolio_result.stop_reason
print(
f"goal reached: scope={sr.scope} project={sr.project_id or '-'} "
f"observed_ore={sr.observed_ore} limit_ore={sr.limit_ore} "
f"stopped_early={portfolio_result.stopped_early}"
)
return 0
# Single-project mode requires PROJECT_ID + --docs-dir (compensating for the relaxed argparse
# required/positional so the legacy contract keeps failing loudly via the refusal surface).
if args.project_id is None or args.docs_dir is None:
print(
"run refused: single-project mode requires PROJECT_ID and --docs-dir "
"(use --portfolio for portfolio mode)",
file=sys.stderr,
)
return 1
if args.live_dry_run:
# S4.2 drill (comparison protocol §4 pkt 2/3): walk the offline path, STOP before the first
# model call, print the run-config. A misconfigured profile (e.g. AZURE with a

View file

@ -0,0 +1,87 @@
"""Load-bearing CLI test for the observable GoalReached stop (S5.3 Step 3, SC1).
DETACH THAT TURNS THIS RED: route ``--goals``/``--ledger`` away from ``run_portfolio`` (drop the
wiring in ``main()``'s portfolio dispatch) → no goal-stop line reaches stdout → the positive arm's
marker assertion fails. The positive+control pair proves the printed scope/øre fields flow from
``run_portfolio``'s actual ``GoalReached``/``PortfolioResult`` values — a canned print could not
produce BOTH the portfolio-scope and the project-scope variant from the same wiring.
Both arms are provably OFFLINE: the goal is already met before any project builds a chat client
(portfolio-hard ``break``s at ``run.py:560-567``; per-project-hard ``continue``s past the only pid at
``run.py:571-579`` no client is ever constructed). Marker value 13731 øre appears nowhere else in
the codebase (repo marker convention). No socket/network is exercised (brief NFR).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from portfolio_optimiser import run
from portfolio_optimiser.ledger import LedgerEntry, SavingsLedger
_MARKER_ORE = 13731 # marker: appears nowhere else in the codebase
@pytest.fixture(autouse=True)
def _isolate_model_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Hermetic env (verbatim from ``test_live_dry_run.py``) — irrelevant here (no client is built),
kept for parity so an accidental client construction can never read the operator's Foundry env."""
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
def _marker_ledger(tmp_path: Path) -> Path:
"""A ledger with exactly one realized entry totalling the marker øre against FV42-GSV-E1 —
reused by both arms (portfolio_total == per_project_total('FV42-GSV-E1') == 13731)."""
led = SavingsLedger()
led.add_realized(
LedgerEntry(
project_id="FV42-GSV-E1",
dimension="energi",
candidate_identity="c-marker",
amount_ore=_MARKER_ORE,
verdict_id="v-marker",
provenance="prior-hitl",
)
)
p = tmp_path / "ledger.json"
led.save(str(p))
return p
def _write_goals(tmp_path: Path, data: dict) -> Path:
p = tmp_path / "goals.json"
p.write_text(json.dumps(data), encoding="utf-8")
return p
def test_portfolio_hard_goal_reached_is_observable_in_cli(tmp_path, capsys) -> None:
"""POSITIVE: a portfolio-hard goal already met by the ledger stops the whole pass offline and
prints an observable goal-stop line carrying scope=portfolio + the marker øre + stopped_early."""
goals = _write_goals(tmp_path, {"portfolio": {"absolute_ore": _MARKER_ORE, "mode": "hard"}})
ledger = _marker_ledger(tmp_path)
rc = run.main(["--portfolio", "--goals", str(goals), "--ledger", str(ledger)])
assert rc == 0
out = capsys.readouterr().out
assert "goal reached: scope=portfolio" in out
assert f"observed_ore={_MARKER_ORE}" in out
assert "stopped_early=True" in out
def test_per_project_hard_goal_control_distinguishes_scope(tmp_path, capsys) -> None:
"""CONTROL (causality): a per-project-hard goal met on the ONLY selected pid skips it (continue,
no client built) scope=project, stopped_early=False, and NO portfolio-scope line. Proves the
printed fields are the actual run_portfolio values, not a canned string."""
goals = _write_goals(
tmp_path, {"per_project": {"FV42-GSV-E1": {"absolute_ore": _MARKER_ORE, "mode": "hard"}}}
)
ledger = _marker_ledger(tmp_path)
rc = run.main(["FV42-GSV-E1", "--portfolio", "--goals", str(goals), "--ledger", str(ledger)])
assert rc == 0
out = capsys.readouterr().out
assert "scope=project" in out
assert "stopped_early=False" in out
assert "scope=portfolio" not in out