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:
parent
663639d376
commit
905b2f9a43
2 changed files with 154 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
87
tests/test_run_cli_loadbearing.py
Normal file
87
tests/test_run_cli_loadbearing.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue