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