feat(s54): --report/--json CLI mode in run.py over value_report
This commit is contained in:
parent
9b81a6e416
commit
19000d89f6
2 changed files with 193 additions and 0 deletions
|
|
@ -59,6 +59,11 @@ from portfolio_optimiser.verdicts import (
|
||||||
capture_verdict,
|
capture_verdict,
|
||||||
load_verdicts_from_dir,
|
load_verdicts_from_dir,
|
||||||
)
|
)
|
||||||
|
from portfolio_optimiser.value_report import (
|
||||||
|
build_value_report,
|
||||||
|
dump_report_json,
|
||||||
|
format_report_text,
|
||||||
|
)
|
||||||
from portfolio_optimiser.workflow import _MAKER_CHECKER_ROLES, fresh_workflow
|
from portfolio_optimiser.workflow import _MAKER_CHECKER_ROLES, fresh_workflow
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -675,8 +680,72 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="offline drill: build contracts/clients/budget, STOP before the first model call",
|
help="offline drill: build contracts/clients/budget, STOP before the first model call",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--report",
|
||||||
|
action="store_true",
|
||||||
|
help="S5.4 read-only value report: roll up the --ledger's realized savings (per-project + "
|
||||||
|
"portfolio totals, flagged cross-dimension overlaps, per-entry provenance) to stdout. "
|
||||||
|
"Mode-exclusive: only --ledger/--json are permitted alongside it; makes NO model calls",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="value report output form (requires --report): emit the roll-up as deterministic JSON "
|
||||||
|
"instead of the human table",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
# S5.4: read-only value-report dispatch — placed FIRST (right after parse_args, BEFORE the
|
||||||
|
# mode-exclusivity block below) so it returns before any model/portfolio path can start and no
|
||||||
|
# later branch can shadow it (the bare `--ledger`-outside-portfolio refusal at the elif below is
|
||||||
|
# left UNCHANGED — a bare --ledger with no --report still flows there and refuses as before).
|
||||||
|
if args.json and not args.report:
|
||||||
|
# A stray --json is never silently ignored (honors S5.3's "refused, never ignored" partition).
|
||||||
|
print("run refused: --json requires --report", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if args.report:
|
||||||
|
# Mode-exclusivity as an ALLOWLIST (not a short blocklist): report mode permits ONLY --ledger
|
||||||
|
# and --json; ANY other distinguishable mode/config flag is refused — else --report --goals
|
||||||
|
# would silently drop --goals, whereas bare --goals is refused below (adding --report must not
|
||||||
|
# suppress an existing refusal). --decision/--rationale are excluded: their non-None argparse
|
||||||
|
# defaults are indistinguishable from an explicit value (exactly as the block below excludes
|
||||||
|
# them); they are inert in report mode.
|
||||||
|
report_forbidden = {
|
||||||
|
"--portfolio": args.portfolio,
|
||||||
|
"--live-dry-run": args.live_dry_run,
|
||||||
|
"PROJECT_ID": args.project_id is not None,
|
||||||
|
"--goals": args.goals is not None,
|
||||||
|
"--docs-dir": args.docs_dir is not None,
|
||||||
|
"--bundle-dir": args.bundle_dir is not None,
|
||||||
|
"--verdict-dir": args.verdict_dir is not None,
|
||||||
|
"--outbox-dir": args.outbox_dir is not None,
|
||||||
|
"--run-id": args.run_id is not None,
|
||||||
|
"--dimension-config": args.dimension_config is not None,
|
||||||
|
}
|
||||||
|
if any(report_forbidden.values()):
|
||||||
|
print(
|
||||||
|
"run report refused: mode-exclusive (only --ledger/--json permitted with --report)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
if not args.ledger:
|
||||||
|
# Guards SavingsLedger.load(None) -> Path(None) TypeError (NOT in the load except tuple).
|
||||||
|
print("run report refused: --report requires --ledger <file>", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
# `report_ledger`, not `ledger`: the portfolio branch below binds `ledger` as
|
||||||
|
# `SavingsLedger | None`, so reusing that name here (type `SavingsLedger`) collides on
|
||||||
|
# mypy's function-scoped declared type.
|
||||||
|
report_ledger = SavingsLedger.load(args.ledger)
|
||||||
|
except (FileNotFoundError, ValidationError, ValueError) as exc:
|
||||||
|
# A load failure must never masquerade as a real zero-savings result (SC5): stderr + rc 1,
|
||||||
|
# no table. Only a successfully-loaded (possibly empty) ledger prints.
|
||||||
|
print(f"run report refused: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
rep = build_value_report(report_ledger) # NB: `rep`, not `report` (`report` is bound below)
|
||||||
|
print(dump_report_json(rep) if args.json else format_report_text(rep))
|
||||||
|
return 0
|
||||||
|
|
||||||
# Step 4: mode-exclusivity validation (structured refusal, NOT argparse.error — keeps the rc 1
|
# Step 4: mode-exclusivity validation (structured refusal, NOT argparse.error — keeps the rc 1
|
||||||
# refusal contract). The two CLI modes are a documented partition: single-project-only flags are
|
# refusal contract). The two CLI modes are a documented partition: single-project-only flags are
|
||||||
# refused in portfolio mode, and --goals/--ledger are refused outside it — never silently ignored.
|
# refused in portfolio mode, and --goals/--ledger are refused outside it — never silently ignored.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ content so the dry-run reaches its offline return.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -266,3 +267,126 @@ def test_verdict_dir_ingested_at_main_level_offline(tmp_path, capsys) -> None:
|
||||||
)
|
)
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
assert "LIVE-DRY-RUN OK" in capsys.readouterr().out
|
assert "LIVE-DRY-RUN OK" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
# --- S5.4: --report / --json read-only value-report mode ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _report_ledger(tmp_path: Path) -> Path:
|
||||||
|
"""A saved ledger with >=2 projects and one cross-dimension overlap (``c-a`` under both
|
||||||
|
``energi`` and ``asfalt`` in FV42 -> counted once, flagged), for the value-report arms.
|
||||||
|
portfolio_total = 1234567 (overlap once) + 500000 = 1734567 øre."""
|
||||||
|
led = SavingsLedger()
|
||||||
|
led.add_realized(
|
||||||
|
LedgerEntry(
|
||||||
|
project_id="FV42-GSV-E1",
|
||||||
|
dimension="energi",
|
||||||
|
candidate_identity="c-a",
|
||||||
|
amount_ore=1234567,
|
||||||
|
verdict_id="v1",
|
||||||
|
provenance="p1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
led.add_realized(
|
||||||
|
LedgerEntry(
|
||||||
|
project_id="FV42-GSV-E1",
|
||||||
|
dimension="asfalt",
|
||||||
|
candidate_identity="c-a",
|
||||||
|
amount_ore=1234567,
|
||||||
|
verdict_id="v2",
|
||||||
|
provenance="p2", # cross-dimension overlap on (FV42-GSV-E1, c-a)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
led.add_realized(
|
||||||
|
LedgerEntry(
|
||||||
|
project_id="RV13-RAS-TP",
|
||||||
|
dimension="energi",
|
||||||
|
candidate_identity="c-b",
|
||||||
|
amount_ore=500000,
|
||||||
|
verdict_id="v3",
|
||||||
|
provenance="p3",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
p = tmp_path / "ledger.json"
|
||||||
|
led.save(str(p))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_prints_table_rc0(tmp_path, capsys) -> None:
|
||||||
|
"""SC3: ``--report --ledger <f>`` -> rc 0; stdout carries a per-project row + the portfolio-total
|
||||||
|
NOK string. Dispatched FIRST, so no PROJECT_ID/--docs-dir is needed (no single-project refusal)."""
|
||||||
|
rc = run.main(["--report", "--ledger", str(_report_ledger(tmp_path))])
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert rc == 0
|
||||||
|
assert "FV42-GSV-E1" in out # a per-project row
|
||||||
|
assert "17\xa0345,67\xa0kr" in out # portfolio total 1734567 øre
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_json_rc0_parses_rollup(tmp_path, capsys) -> None:
|
||||||
|
"""SC4: ``--report ... --json`` -> rc 0 and ``json.loads(stdout)`` yields the roll-up
|
||||||
|
(int portfolio total, per_project dict, overlaps as JSON lists, provenance list)."""
|
||||||
|
rc = run.main(["--report", "--ledger", str(_report_ledger(tmp_path)), "--json"])
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert rc == 0
|
||||||
|
payload = json.loads(out)
|
||||||
|
assert payload["portfolio_total_ore"] == 1734567
|
||||||
|
assert payload["per_project"]["FV42-GSV-E1"] == 1234567
|
||||||
|
assert isinstance(payload["overlaps"], list)
|
||||||
|
assert ["FV42-GSV-E1", "c-a"] in payload["overlaps"] # tuple serialized as a JSON array
|
||||||
|
assert isinstance(payload["provenance"], list)
|
||||||
|
assert len(payload["provenance"]) == 3 # one ProvenanceLine per ledger entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_missing_ledger_file_rc1(capsys) -> None:
|
||||||
|
"""SC5: ``--report --ledger /nonexistent`` -> rc 1, stderr non-empty, NO table on stdout (a load
|
||||||
|
failure must never masquerade as a real zero-savings result)."""
|
||||||
|
rc = run.main(["--report", "--ledger", "/nonexistent-ledger.json"])
|
||||||
|
cap = capsys.readouterr()
|
||||||
|
assert rc == 1
|
||||||
|
assert cap.err.strip()
|
||||||
|
assert cap.out == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_malformed_ledger_rc1(tmp_path, capsys) -> None:
|
||||||
|
"""SC5: a malformed-row ledger file -> rc 1 (``ValidationError`` surfaced as a refusal)."""
|
||||||
|
bad = tmp_path / "bad.json"
|
||||||
|
bad.write_text('[{"project_id": "P1"}]', encoding="utf-8") # missing required fields
|
||||||
|
rc = run.main(["--report", "--ledger", str(bad)])
|
||||||
|
assert rc == 1
|
||||||
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_without_ledger_rc1_no_traceback(capsys) -> None:
|
||||||
|
"""Major-#1 guard: ``--report`` with no ``--ledger`` -> rc 1 with a 'requires --ledger' message
|
||||||
|
and no traceback (guards ``SavingsLedger.load(None)`` -> ``Path(None)`` TypeError)."""
|
||||||
|
rc = run.main(["--report"])
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert rc == 1
|
||||||
|
assert "requires --ledger" in err
|
||||||
|
assert "Traceback" not in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_with_portfolio_refuses(tmp_path, capsys) -> None:
|
||||||
|
"""Major-#3 partition: ``--report`` + ``--portfolio`` -> rc 1 (mode-exclusive)."""
|
||||||
|
rc = run.main(["--report", "--portfolio", "--ledger", str(_report_ledger(tmp_path))])
|
||||||
|
assert rc == 1
|
||||||
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_with_goals_refuses(tmp_path, capsys) -> None:
|
||||||
|
"""Major-#3 / P2-2 allowlist: ``--report`` + ``--goals`` (a config flag) -> rc 1. The allowlist
|
||||||
|
rejects config flags too, not just the two mode flags — else ``--goals`` would be silently
|
||||||
|
dropped, whereas bare ``--goals`` is refused (adding ``--report`` must not suppress a refusal)."""
|
||||||
|
goals = tmp_path / "goals.json"
|
||||||
|
goals.write_text('{"portfolio": {"absolute_ore": 1, "mode": "hard"}}', encoding="utf-8")
|
||||||
|
rc = run.main(["--report", "--goals", str(goals), "--ledger", str(_report_ledger(tmp_path))])
|
||||||
|
assert rc == 1
|
||||||
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_without_report_refuses(tmp_path, capsys) -> None:
|
||||||
|
"""Major-#3 partition: ``--json`` without ``--report`` -> rc 1 (a stray --json is never silently
|
||||||
|
ignored)."""
|
||||||
|
rc = run.main(["--json", "--ledger", str(_report_ledger(tmp_path))])
|
||||||
|
assert rc == 1
|
||||||
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue