feat(fase1): hard/soft goal-stop in run_portfolio on accumulated ledger (F1)
This commit is contained in:
parent
c6f62d41db
commit
16b6d80b82
4 changed files with 270 additions and 6 deletions
|
|
@ -26,14 +26,16 @@ durable learned verdict captured out-of-band in the VerdictStore (D7-portable).
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from dataclasses import dataclass, replace
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Any, Literal
|
||||
|
||||
from agent_framework import BaseChatClient, SessionContext
|
||||
|
||||
from portfolio_optimiser.backends import Profile, get_backend, resolve_model
|
||||
from portfolio_optimiser.budget import Budget, BudgetMiddleware, TokenMeter
|
||||
from portfolio_optimiser.contracts import load_contracts
|
||||
from portfolio_optimiser.contracts import GoalConfig, GoalContract, load_contracts
|
||||
from portfolio_optimiser.ledger import SavingsLedger
|
||||
from portfolio_optimiser.datasource import (
|
||||
bundle_citations,
|
||||
chunk_dict_to_citation,
|
||||
|
|
@ -77,6 +79,20 @@ class RunResult:
|
|||
checker_verdict: str = "absent"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GoalReached:
|
||||
"""A savings-goal signal VALUE (Step 8, SC6) — NOT an exception. Structured like
|
||||
``BudgetExceeded`` (``budget.py:22-34``) but semantically SUCCESS (the goal was reached), not
|
||||
resource exhaustion (H1). Used as a ``stop_reason`` value + a loop ``break``, never ``raise``d.
|
||||
``scope`` is ``"portfolio"`` (the whole pass) or ``"project"`` (one pid); ``limit_ore`` is the
|
||||
threshold that was met, ``observed_ore`` the accumulated realized sum that met it (``>=``)."""
|
||||
|
||||
scope: Literal["project", "portfolio"]
|
||||
project_id: str | None
|
||||
limit_ore: int
|
||||
observed_ore: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortfolioResult:
|
||||
"""The outcome of a sequential fan-out over N projects (SC2).
|
||||
|
|
@ -86,7 +102,8 @@ class PortfolioResult:
|
|||
The remaining fields are a thin aggregate over ``runs``: ``validated_count`` /
|
||||
``rejected_count`` partition the outcomes; ``sum_claimed_saving_nok`` totals the claimed
|
||||
saving of the validated proposals only; ``sum_token_usage`` totals every run's
|
||||
provenance token usage."""
|
||||
provenance token usage. ``stopped_early`` / ``stop_reason`` record a Step-8 goal-stop: they
|
||||
default so the frozen aggregate and every existing constructor call are unaffected."""
|
||||
|
||||
runs: tuple[RunResult, ...]
|
||||
store: VerdictStore
|
||||
|
|
@ -94,6 +111,8 @@ class PortfolioResult:
|
|||
rejected_count: int
|
||||
sum_claimed_saving_nok: float
|
||||
sum_token_usage: int
|
||||
stopped_early: bool = False
|
||||
stop_reason: GoalReached | None = None
|
||||
|
||||
|
||||
def _authored_texts(result: Any, name: str) -> list[str]:
|
||||
|
|
@ -389,11 +408,31 @@ def _aggregate(runs: tuple[RunResult, ...], store: VerdictStore) -> PortfolioRes
|
|||
)
|
||||
|
||||
|
||||
def _to_ore(nok: float) -> int:
|
||||
"""NOK float -> integer øre, deterministically (Decimal, mirrors ``ledger.realize``)."""
|
||||
return int((Decimal(str(nok)) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _goal_limit_if_reached(goal: GoalContract, observed_ore: int, baseline_ore: int) -> int | None:
|
||||
"""The threshold ``observed_ore`` MET (``>=``), or ``None`` if the goal is not yet reached. An
|
||||
absolute-øre target compares directly; a percent target is taken against ``baseline_ore`` (the
|
||||
addressable cost in øre). When both are set, reaching EITHER counts as met."""
|
||||
if goal.absolute_ore is not None and observed_ore >= goal.absolute_ore:
|
||||
return goal.absolute_ore
|
||||
if goal.percent is not None:
|
||||
threshold = int(goal.percent / 100 * baseline_ore)
|
||||
if observed_ore >= threshold:
|
||||
return threshold
|
||||
return None
|
||||
|
||||
|
||||
async def run_portfolio(
|
||||
project_ids: Sequence[str] | None = None,
|
||||
profile: Profile | str = Profile.LOCAL,
|
||||
*,
|
||||
dimension: Dimension | None = None,
|
||||
ledger: SavingsLedger | None = None,
|
||||
goals: GoalConfig | None = None,
|
||||
store: VerdictStore | None = None,
|
||||
client_factory: Callable[[str], BaseChatClient] | None = None,
|
||||
max_rounds: int = 3,
|
||||
|
|
@ -407,15 +446,53 @@ async def run_portfolio(
|
|||
across every run, so a verdict on project k informs the ExpeL retrieval of project k+1 (the
|
||||
cross-project learning loop). ``project_ids`` defaults to every loaded project; an unknown id
|
||||
raises ``ValueError``. ``meter_factory`` (test seam) supplies a per-project meter — inject a
|
||||
shared meter to make the isolation guard go red (SC3)."""
|
||||
shared meter to make the isolation guard go red (SC3).
|
||||
|
||||
Step 8 goal-stop (SC6): ``ledger`` carries EARLIER, out-of-band HITL realizations (the long file
|
||||
loop / Steg-7 role split — ``run_project`` never realizes mid-pass, C3), so the check reads an
|
||||
ACCUMULATED sum, it does not build one during the pass. BEFORE running each pid, the accumulated
|
||||
realized sum is compared to ``goals`` with a ``>=`` boundary (reached, not exceeded — H1): a HARD
|
||||
portfolio goal ``break``s the pass (``stopped_early``); a HARD per-project goal SKIPS that pid
|
||||
(its further runs are future passes, not more runs here); a SOFT goal flags ``stop_reason`` but
|
||||
continues. Because the ledger is static during the pass, a reached goal is observed on the first
|
||||
iteration. ``stop_reason`` surfaces the first goal event; a per-project skip is also observable
|
||||
as the pid's absence from ``runs``."""
|
||||
projects = {p.id: p for p in load_reference_projects()}
|
||||
ids = list(project_ids) if project_ids is not None else list(projects)
|
||||
store = store if store is not None else VerdictStore(verdicts=[])
|
||||
ledger = ledger if ledger is not None else SavingsLedger(entries=[])
|
||||
goals = goals if goals is not None else GoalConfig()
|
||||
portfolio_baseline_ore = _to_ore(sum(projects[p].total_cost for p in ids if p in projects))
|
||||
|
||||
runs: list[RunResult] = []
|
||||
stopped_early = False
|
||||
stop_reason: GoalReached | None = None
|
||||
for pid in ids:
|
||||
if pid not in projects:
|
||||
raise ValueError(f"unknown project_id: {pid!r}")
|
||||
project = projects[pid]
|
||||
|
||||
if goals.portfolio is not None:
|
||||
observed = ledger.portfolio_total()
|
||||
limit = _goal_limit_if_reached(goals.portfolio, observed, portfolio_baseline_ore)
|
||||
if limit is not None:
|
||||
if goals.portfolio.mode == "hard":
|
||||
stopped_early = True
|
||||
stop_reason = GoalReached("portfolio", None, limit, observed)
|
||||
break
|
||||
if stop_reason is None:
|
||||
stop_reason = GoalReached("portfolio", None, limit, observed) # soft flag
|
||||
|
||||
per_project_goal = goals.per_project.get(pid)
|
||||
if per_project_goal is not None:
|
||||
observed = ledger.per_project_total(pid)
|
||||
limit = _goal_limit_if_reached(per_project_goal, observed, _to_ore(project.total_cost))
|
||||
if limit is not None:
|
||||
if stop_reason is None:
|
||||
stop_reason = GoalReached("project", pid, limit, observed)
|
||||
if per_project_goal.mode == "hard":
|
||||
continue # skip THIS pid; the rest of the pass proceeds
|
||||
|
||||
result = await run_project(
|
||||
pid,
|
||||
profile,
|
||||
|
|
@ -430,7 +507,11 @@ async def run_portfolio(
|
|||
meter=meter_factory() if meter_factory is not None else None,
|
||||
)
|
||||
runs.append(result)
|
||||
return _aggregate(tuple(runs), store)
|
||||
|
||||
base = _aggregate(tuple(runs), store)
|
||||
if stopped_early or stop_reason is not None:
|
||||
return replace(base, stopped_early=stopped_early, stop_reason=stop_reason)
|
||||
return base
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue