feat: reporting-allocate AI-plan cost across work
Split a booked AI-plan fact across workplans or repos using measured tokens, measured+estimated tokens, session counts, or an even split. Unmeasured remainder stays a residual. The record is not resource-control AllocationEvidence and does not create booked spend.
This commit is contained in:
parent
8916f3c4d0
commit
efd1b31001
10 changed files with 646 additions and 3 deletions
|
|
@ -31,6 +31,8 @@ uv run finhub ledger set-entitlement --provider anthropic --plan claude-max --pe
|
||||||
uv run finhub ledger plan-month --period 2026-08
|
uv run finhub ledger plan-month --period 2026-08
|
||||||
uv run finhub ledger ingest-session-tokens tests/fixtures/session-tokens-2026-08.json
|
uv run finhub ledger ingest-session-tokens tests/fixtures/session-tokens-2026-08.json
|
||||||
uv run finhub ledger session-tokens --period 2026-08
|
uv run finhub ledger session-tokens --period 2026-08
|
||||||
|
uv run finhub ledger allocate-plan --fact FACT --method measured_token_share
|
||||||
|
uv run finhub ledger plan-allocations
|
||||||
uv run finhub ledger summary
|
uv run finhub ledger summary
|
||||||
|
|
||||||
# CLI — scheduled evaluation (cron/systemd friendly)
|
# CLI — scheduled evaluation (cron/systemd friendly)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ uv run finhub ledger set-entitlement --provider anthropic --plan claude-max --pe
|
||||||
uv run finhub ledger plan-month --period 2026-08
|
uv run finhub ledger plan-month --period 2026-08
|
||||||
uv run finhub ledger ingest-session-tokens tests/fixtures/session-tokens-2026-08.json
|
uv run finhub ledger ingest-session-tokens tests/fixtures/session-tokens-2026-08.json
|
||||||
uv run finhub ledger session-tokens --period 2026-08
|
uv run finhub ledger session-tokens --period 2026-08
|
||||||
|
uv run finhub ledger allocate-plan --fact FACT --method measured_token_share
|
||||||
|
uv run finhub ledger plan-allocations
|
||||||
uv run finhub ledger set-price --client acme --application portal --instance prod-01 --period 2026-07 --amount 100 --source agreement-2026-01
|
uv run finhub ledger set-price --client acme --application portal --instance prod-01 --period 2026-07 --amount 100 --source agreement-2026-01
|
||||||
uv run finhub ledger margins
|
uv run finhub ledger margins
|
||||||
uv run finhub ledger allocations
|
uv run finhub ledger allocations
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ from fin_hub.services.alerts import evaluate_budget_alerts
|
||||||
from fin_hub.services.allocation import shared_cost_allocations
|
from fin_hub.services.allocation import shared_cost_allocations
|
||||||
from fin_hub.services.billing import build_billing_basis
|
from fin_hub.services.billing import build_billing_basis
|
||||||
from fin_hub.services.evaluate import evaluate_runway
|
from fin_hub.services.evaluate import evaluate_runway
|
||||||
|
from fin_hub.services.reporting_allocation import (
|
||||||
|
allocate_ai_plan_report,
|
||||||
|
list_ai_plan_allocations,
|
||||||
|
)
|
||||||
from fin_hub.services.session_tokens import (
|
from fin_hub.services.session_tokens import (
|
||||||
ingest_session_token_file,
|
ingest_session_token_file,
|
||||||
list_current_session_token_evidence,
|
list_current_session_token_evidence,
|
||||||
|
|
@ -231,6 +235,25 @@ def _cmd_ledger_session_tokens(args: argparse.Namespace) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_ledger_allocate_plan(args: argparse.Namespace) -> int:
|
||||||
|
report = allocate_ai_plan_report(
|
||||||
|
args.fact,
|
||||||
|
method=args.method,
|
||||||
|
estimator_version=args.estimator_version,
|
||||||
|
session_token_record_id=args.session_tokens,
|
||||||
|
target_scope=args.scope,
|
||||||
|
ledger_path=_ledger_path(args),
|
||||||
|
)
|
||||||
|
print(json.dumps(report.as_dict(), indent=2, default=str))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_ledger_plan_allocations(args: argparse.Namespace) -> int:
|
||||||
|
reports = list_ai_plan_allocations(ledger_path=_ledger_path(args))
|
||||||
|
print(json.dumps([report.as_dict() for report in reports], indent=2, default=str))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _cmd_ledger_allocations(args: argparse.Namespace) -> int:
|
def _cmd_ledger_allocations(args: argparse.Namespace) -> int:
|
||||||
reports = shared_cost_allocations(ledger_path=_ledger_path(args))
|
reports = shared_cost_allocations(ledger_path=_ledger_path(args))
|
||||||
print(json.dumps([report.as_dict() for report in reports], indent=2, default=str))
|
print(json.dumps([report.as_dict() for report in reports], indent=2, default=str))
|
||||||
|
|
@ -423,6 +446,36 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
ledger_session_tokens.add_argument("--ledger", help="Ledger database path")
|
ledger_session_tokens.add_argument("--ledger", help="Ledger database path")
|
||||||
ledger_session_tokens.set_defaults(func=_cmd_ledger_session_tokens)
|
ledger_session_tokens.set_defaults(func=_cmd_ledger_session_tokens)
|
||||||
|
|
||||||
|
ledger_allocate_plan = ledger_sub.add_parser(
|
||||||
|
"allocate-plan",
|
||||||
|
help="Reporting-allocate a booked AI-plan fact across work (not RC allocation)",
|
||||||
|
)
|
||||||
|
ledger_allocate_plan.add_argument("--fact", required=True, help="financial_fact_id")
|
||||||
|
ledger_allocate_plan.add_argument(
|
||||||
|
"--method",
|
||||||
|
required=True,
|
||||||
|
choices=[
|
||||||
|
"measured_token_share",
|
||||||
|
"measured_estimated_token_share",
|
||||||
|
"session_count_share",
|
||||||
|
"even_split",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
ledger_allocate_plan.add_argument("--estimator-version", default="0.1")
|
||||||
|
ledger_allocate_plan.add_argument("--session-tokens", help="session_token_evidence record_id")
|
||||||
|
ledger_allocate_plan.add_argument(
|
||||||
|
"--scope", default="workplan", choices=["workplan", "repo"]
|
||||||
|
)
|
||||||
|
ledger_allocate_plan.add_argument("--ledger", help="Ledger database path")
|
||||||
|
ledger_allocate_plan.set_defaults(func=_cmd_ledger_allocate_plan)
|
||||||
|
|
||||||
|
ledger_plan_allocations = ledger_sub.add_parser(
|
||||||
|
"plan-allocations",
|
||||||
|
help="List current AI-plan reporting allocations",
|
||||||
|
)
|
||||||
|
ledger_plan_allocations.add_argument("--ledger", help="Ledger database path")
|
||||||
|
ledger_plan_allocations.set_defaults(func=_cmd_ledger_plan_allocations)
|
||||||
|
|
||||||
ledger_allocations = ledger_sub.add_parser(
|
ledger_allocations = ledger_sub.add_parser(
|
||||||
"allocations",
|
"allocations",
|
||||||
help="Reconcile resource-control allocation evidence to booked facts",
|
help="Reconcile resource-control allocation evidence to booked facts",
|
||||||
|
|
|
||||||
78
src/fin_hub/schemas/reporting_allocation.py
Normal file
78
src/fin_hub/schemas/reporting_allocation.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""Fin-hub reporting allocation of a booked AI-plan fact onto work."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from fin_hub.money import money
|
||||||
|
|
||||||
|
REPORTING_METHODS = (
|
||||||
|
"measured_token_share",
|
||||||
|
"measured_estimated_token_share",
|
||||||
|
"session_count_share",
|
||||||
|
"even_split",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingAllocationShare(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
target_key: str = Field(min_length=1, max_length=256)
|
||||||
|
share: Decimal = Field(ge=0, le=1)
|
||||||
|
driver_value: int = Field(ge=0)
|
||||||
|
|
||||||
|
@field_validator("share", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_share(cls, value):
|
||||||
|
return money(value, non_negative=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingAllocation(BaseModel):
|
||||||
|
"""Local reporting allocation. Not resource-control AllocationEvidence."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
schema_version: Literal["0.1"] = "0.1"
|
||||||
|
record_type: Literal["reporting_allocation"] = "reporting_allocation"
|
||||||
|
record_id: str = Field(min_length=1, max_length=256)
|
||||||
|
revision_of: str | None = None
|
||||||
|
financial_fact_id: str = Field(min_length=1)
|
||||||
|
method: Literal[
|
||||||
|
"measured_token_share",
|
||||||
|
"measured_estimated_token_share",
|
||||||
|
"session_count_share",
|
||||||
|
"even_split",
|
||||||
|
]
|
||||||
|
estimator_version: str = Field(min_length=1, max_length=64)
|
||||||
|
session_token_record_id: str | None = None
|
||||||
|
period_start: date
|
||||||
|
period_end: date
|
||||||
|
currency: str
|
||||||
|
booked_amount: Decimal
|
||||||
|
shares: list[ReportingAllocationShare]
|
||||||
|
residual_share: Decimal = Field(ge=0, le=1)
|
||||||
|
residual_reason: Literal["none", "unmeasured", "unattributed", "no_targets"]
|
||||||
|
source_evidence: list[str] = Field(min_length=1)
|
||||||
|
|
||||||
|
@field_validator("booked_amount", "residual_share", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_money_fields(cls, value):
|
||||||
|
return money(value, non_negative=True)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_shares(self) -> "ReportingAllocation":
|
||||||
|
keys = [share.target_key for share in self.shares]
|
||||||
|
if len(set(keys)) != len(keys):
|
||||||
|
raise ValueError("reporting allocation target keys must be unique")
|
||||||
|
total = sum((share.share for share in self.shares), Decimal("0.00"))
|
||||||
|
if total + self.residual_share != Decimal("1.00"):
|
||||||
|
raise ValueError("allocation shares plus residual_share must equal 1")
|
||||||
|
if self.residual_share == Decimal("0.00") and self.residual_reason != "none":
|
||||||
|
raise ValueError("zero residual must use residual_reason=none")
|
||||||
|
if self.residual_share > Decimal("0.00") and self.residual_reason == "none":
|
||||||
|
raise ValueError("nonzero residual requires a residual_reason")
|
||||||
|
return self
|
||||||
|
|
@ -232,9 +232,12 @@ def ingest_planning_evidence(
|
||||||
) -> PlanningEvidence:
|
) -> PlanningEvidence:
|
||||||
"""Validate and idempotently retain planning evidence outside booked spend."""
|
"""Validate and idempotently retain planning evidence outside booked spend."""
|
||||||
|
|
||||||
if payload.get("record_type") == "session_token_evidence":
|
if payload.get("record_type") in {
|
||||||
|
"session_token_evidence",
|
||||||
|
"reporting_allocation",
|
||||||
|
}:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"session_token_evidence is not resource-control planning evidence"
|
f"{payload.get('record_type')} is not resource-control planning evidence"
|
||||||
)
|
)
|
||||||
ledger = ledger_path or default_ledger_path()
|
ledger = ledger_path or default_ledger_path()
|
||||||
with _connect(ledger) as conn:
|
with _connect(ledger) as conn:
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,17 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||||
recorded_at TEXT NOT NULL
|
recorded_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reporting_allocations (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
revision_of TEXT,
|
||||||
|
financial_fact_id TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
estimator_version TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
is_current INTEGER NOT NULL DEFAULT 1,
|
||||||
|
recorded_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
columns = {row["name"] for row in conn.execute("PRAGMA table_info(ledger_entries)")}
|
columns = {row["name"] for row in conn.execute("PRAGMA table_info(ledger_entries)")}
|
||||||
|
|
|
||||||
343
src/fin_hub/services/reporting_allocation.py
Normal file
343
src/fin_hub/services/reporting_allocation.py
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
"""Allocate a booked AI-plan fact across work as a reporting overlay."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fin_hub.money import MONEY_QUANTUM, minor_money, money, money_minor
|
||||||
|
from fin_hub.schemas.reporting_allocation import (
|
||||||
|
REPORTING_METHODS,
|
||||||
|
ReportingAllocation,
|
||||||
|
ReportingAllocationShare,
|
||||||
|
)
|
||||||
|
from fin_hub.schemas.session_tokens import SessionTokenAssociation, SessionTokenEvidence
|
||||||
|
from fin_hub.services.allocation import AllocatedTarget, _split_minor_units
|
||||||
|
from fin_hub.services.ledger import _connect, _utc_now, default_ledger_path
|
||||||
|
from fin_hub.services.session_tokens import list_current_session_token_evidence
|
||||||
|
|
||||||
|
METHOD_KINDS: dict[str, frozenset[str]] = {
|
||||||
|
"measured_token_share": frozenset({"measured"}),
|
||||||
|
"measured_estimated_token_share": frozenset({"measured", "estimated"}),
|
||||||
|
"session_count_share": frozenset({"measured", "allocated", "estimated"}),
|
||||||
|
"even_split": frozenset({"measured", "allocated", "estimated"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_reporting_allocation_schema(conn) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS reporting_allocations (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
revision_of TEXT,
|
||||||
|
financial_fact_id TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
estimator_version TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
is_current INTEGER NOT NULL DEFAULT 1,
|
||||||
|
recorded_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _month_bounds(period_month: str) -> tuple[date, date]:
|
||||||
|
year, month = (int(part) for part in period_month.split("-"))
|
||||||
|
return date(year, month, 1), date(year, month, calendar.monthrange(year, month)[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _overlaps(record: SessionTokenEvidence, start: date, end: date) -> bool:
|
||||||
|
return record.period_start <= end and record.period_end >= start
|
||||||
|
|
||||||
|
|
||||||
|
def _driver(association: SessionTokenAssociation, method: str) -> int:
|
||||||
|
if method == "session_count_share":
|
||||||
|
return association.event_count
|
||||||
|
return association.tokens_in + association.tokens_out
|
||||||
|
|
||||||
|
|
||||||
|
def _pool(record: SessionTokenEvidence, method: str) -> int:
|
||||||
|
kinds = METHOD_KINDS[method]
|
||||||
|
if method == "session_count_share":
|
||||||
|
return sum(
|
||||||
|
slice_.event_count
|
||||||
|
for kind, slice_ in record.totals.items()
|
||||||
|
if kind in kinds
|
||||||
|
)
|
||||||
|
return sum(
|
||||||
|
slice_.tokens_in + slice_.tokens_out
|
||||||
|
for kind, slice_ in record.totals.items()
|
||||||
|
if kind in kinds and slice_.event_count > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_token_record(
|
||||||
|
fact_start: date,
|
||||||
|
fact_end: date,
|
||||||
|
*,
|
||||||
|
session_token_record_id: str | None,
|
||||||
|
ledger_path: Path,
|
||||||
|
) -> SessionTokenEvidence | None:
|
||||||
|
records = list_current_session_token_evidence(ledger_path=ledger_path)
|
||||||
|
if session_token_record_id is not None:
|
||||||
|
matches = [row for row in records if row.record_id == session_token_record_id]
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"session_token_record_id {session_token_record_id} is not current"
|
||||||
|
)
|
||||||
|
return matches[0]
|
||||||
|
overlapping = [row for row in records if _overlaps(row, fact_start, fact_end)]
|
||||||
|
if len(overlapping) > 1:
|
||||||
|
raise ValueError("multiple session-token records overlap this fact; pass an id")
|
||||||
|
return overlapping[0] if overlapping else None
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_shares(
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
target_scope: Literal["workplan", "repo"],
|
||||||
|
token_record: SessionTokenEvidence | None,
|
||||||
|
) -> tuple[list[ReportingAllocationShare], Decimal, str]:
|
||||||
|
if method == "even_split" and token_record is None:
|
||||||
|
return [], Decimal("1.00"), "no_targets"
|
||||||
|
if token_record is None:
|
||||||
|
return [], Decimal("1.00"), "unmeasured"
|
||||||
|
|
||||||
|
kinds = METHOD_KINDS[method]
|
||||||
|
grouped: dict[str, int] = {}
|
||||||
|
for association in token_record.associations:
|
||||||
|
if association.scope != target_scope or association.measurement_kind not in kinds:
|
||||||
|
continue
|
||||||
|
key = f"{association.scope}:{association.scope_id}"
|
||||||
|
grouped[key] = grouped.get(key, 0) + _driver(association, method)
|
||||||
|
|
||||||
|
if method == "even_split":
|
||||||
|
if not grouped:
|
||||||
|
return [], Decimal("1.00"), "no_targets"
|
||||||
|
share = money(Decimal("1") / Decimal(len(grouped)))
|
||||||
|
shares = [
|
||||||
|
ReportingAllocationShare(target_key=key, share=share, driver_value=1)
|
||||||
|
for key in sorted(grouped)
|
||||||
|
]
|
||||||
|
assigned = sum((item.share for item in shares), Decimal("0.00"))
|
||||||
|
residual = money(Decimal("1.00") - assigned)
|
||||||
|
return shares, residual, "none" if residual == Decimal("0.00") else "unattributed"
|
||||||
|
|
||||||
|
pool = _pool(token_record, method)
|
||||||
|
associated = sum(grouped.values())
|
||||||
|
if pool == 0:
|
||||||
|
return [], Decimal("1.00"), "unmeasured"
|
||||||
|
if associated > pool:
|
||||||
|
raise ValueError("association drivers exceed the session-token pool")
|
||||||
|
shares = [
|
||||||
|
ReportingAllocationShare(
|
||||||
|
target_key=key,
|
||||||
|
share=money(Decimal(value) / Decimal(pool)),
|
||||||
|
driver_value=value,
|
||||||
|
)
|
||||||
|
for key, value in sorted(grouped.items())
|
||||||
|
if value > 0
|
||||||
|
]
|
||||||
|
assigned = sum((item.share for item in shares), Decimal("0.00"))
|
||||||
|
residual = money(Decimal("1.00") - assigned)
|
||||||
|
if residual == Decimal("0.00"):
|
||||||
|
reason = "none"
|
||||||
|
elif associated == 0:
|
||||||
|
reason = "unattributed"
|
||||||
|
else:
|
||||||
|
reason = "unmeasured"
|
||||||
|
return shares, residual, reason
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReportingAllocationReport:
|
||||||
|
record_id: str
|
||||||
|
revision_of: str | None
|
||||||
|
financial_fact_id: str
|
||||||
|
method: str
|
||||||
|
estimator_version: str
|
||||||
|
session_token_record_id: str | None
|
||||||
|
period_start: date
|
||||||
|
period_end: date
|
||||||
|
currency: str
|
||||||
|
booked_amount: Decimal
|
||||||
|
targets: tuple[AllocatedTarget, ...]
|
||||||
|
residual_share: Decimal
|
||||||
|
residual_amount: Decimal
|
||||||
|
residual_reason: str
|
||||||
|
source_evidence: tuple[str, ...]
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_report(record: ReportingAllocation) -> ReportingAllocationReport:
|
||||||
|
residual_key = "__residual__"
|
||||||
|
split = _split_minor_units(
|
||||||
|
money_minor(record.booked_amount),
|
||||||
|
[
|
||||||
|
*[(share.target_key, share.share) for share in record.shares],
|
||||||
|
(residual_key, record.residual_share),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return ReportingAllocationReport(
|
||||||
|
record_id=record.record_id,
|
||||||
|
revision_of=record.revision_of,
|
||||||
|
financial_fact_id=record.financial_fact_id,
|
||||||
|
method=record.method,
|
||||||
|
estimator_version=record.estimator_version,
|
||||||
|
session_token_record_id=record.session_token_record_id,
|
||||||
|
period_start=record.period_start,
|
||||||
|
period_end=record.period_end,
|
||||||
|
currency=record.currency,
|
||||||
|
booked_amount=record.booked_amount,
|
||||||
|
targets=tuple(
|
||||||
|
AllocatedTarget(
|
||||||
|
target_key=share.target_key,
|
||||||
|
share=share.share,
|
||||||
|
amount=minor_money(split[share.target_key][0]),
|
||||||
|
rounding_adjustment=split[share.target_key][1] * MONEY_QUANTUM,
|
||||||
|
)
|
||||||
|
for share in record.shares
|
||||||
|
),
|
||||||
|
residual_share=record.residual_share,
|
||||||
|
residual_amount=minor_money(split[residual_key][0]),
|
||||||
|
residual_reason=record.residual_reason,
|
||||||
|
source_evidence=tuple(record.source_evidence),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def allocate_ai_plan_report(
|
||||||
|
financial_fact_id: str,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
estimator_version: str = "0.1",
|
||||||
|
session_token_record_id: str | None = None,
|
||||||
|
target_scope: Literal["workplan", "repo"] = "workplan",
|
||||||
|
ledger_path: Path | None = None,
|
||||||
|
) -> ReportingAllocationReport:
|
||||||
|
if method not in REPORTING_METHODS:
|
||||||
|
raise ValueError(
|
||||||
|
"method must be measured_token_share, measured_estimated_token_share, "
|
||||||
|
"session_count_share, or even_split"
|
||||||
|
)
|
||||||
|
ledger = ledger_path or default_ledger_path()
|
||||||
|
with _connect(ledger) as conn:
|
||||||
|
_ensure_reporting_allocation_schema(conn)
|
||||||
|
fact = conn.execute(
|
||||||
|
"SELECT * FROM ledger_entries "
|
||||||
|
"WHERE financial_fact_id = ? AND is_current = 1",
|
||||||
|
(financial_fact_id,),
|
||||||
|
).fetchone()
|
||||||
|
if fact is None:
|
||||||
|
raise ValueError("financial_fact_id must reference a current fact")
|
||||||
|
if fact["source_type"] != "ai-plan":
|
||||||
|
raise ValueError("reporting allocation only applies to ai-plan booked facts")
|
||||||
|
period_month = fact["period_month"]
|
||||||
|
currency = fact["currency"]
|
||||||
|
booked_minor = int(fact["amount_minor"])
|
||||||
|
|
||||||
|
fact_start, fact_end = _month_bounds(period_month)
|
||||||
|
token_record = _select_token_record(
|
||||||
|
fact_start,
|
||||||
|
fact_end,
|
||||||
|
session_token_record_id=session_token_record_id,
|
||||||
|
ledger_path=ledger,
|
||||||
|
)
|
||||||
|
shares, residual_share, residual_reason = _compute_shares(
|
||||||
|
method=method,
|
||||||
|
target_scope=target_scope,
|
||||||
|
token_record=token_record,
|
||||||
|
)
|
||||||
|
source_evidence = [f"ledger:{financial_fact_id}"]
|
||||||
|
if token_record is not None:
|
||||||
|
source_evidence.extend(token_record.source_evidence)
|
||||||
|
token_id = None if token_record is None else token_record.record_id
|
||||||
|
record_id = (
|
||||||
|
f"reporting-alloc:{financial_fact_id}:{method}:{estimator_version}"
|
||||||
|
f":{token_id or 'none'}"
|
||||||
|
)
|
||||||
|
payload = ReportingAllocation(
|
||||||
|
record_id=record_id,
|
||||||
|
financial_fact_id=financial_fact_id,
|
||||||
|
method=method, # type: ignore[arg-type]
|
||||||
|
estimator_version=estimator_version,
|
||||||
|
session_token_record_id=token_id,
|
||||||
|
period_start=fact_start,
|
||||||
|
period_end=fact_end,
|
||||||
|
currency=currency,
|
||||||
|
booked_amount=minor_money(booked_minor),
|
||||||
|
shares=shares,
|
||||||
|
residual_share=residual_share,
|
||||||
|
residual_reason=residual_reason, # type: ignore[arg-type]
|
||||||
|
source_evidence=source_evidence,
|
||||||
|
)
|
||||||
|
canonical = payload.model_dump_json()
|
||||||
|
with _connect(ledger) as conn:
|
||||||
|
_ensure_reporting_allocation_schema(conn)
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT payload_json, is_current FROM reporting_allocations WHERE record_id = ?",
|
||||||
|
(record_id,),
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
if json.loads(existing["payload_json"]) != json.loads(canonical):
|
||||||
|
raise ValueError("record_id already exists with different content")
|
||||||
|
return _to_report(payload)
|
||||||
|
current = conn.execute(
|
||||||
|
"SELECT record_id FROM reporting_allocations "
|
||||||
|
"WHERE financial_fact_id = ? AND method = ? AND estimator_version = ? "
|
||||||
|
"AND is_current = 1",
|
||||||
|
(financial_fact_id, method, estimator_version),
|
||||||
|
).fetchone()
|
||||||
|
revision_of = None
|
||||||
|
if current is not None:
|
||||||
|
revision_of = current["record_id"]
|
||||||
|
payload = payload.model_copy(update={"revision_of": revision_of})
|
||||||
|
canonical = payload.model_dump_json()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE reporting_allocations SET is_current = 0 WHERE record_id = ?",
|
||||||
|
(revision_of,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO reporting_allocations (
|
||||||
|
record_id, revision_of, financial_fact_id, method, estimator_version,
|
||||||
|
payload_json, is_current, recorded_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, 1, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
payload.record_id,
|
||||||
|
revision_of,
|
||||||
|
financial_fact_id,
|
||||||
|
method,
|
||||||
|
estimator_version,
|
||||||
|
canonical,
|
||||||
|
_utc_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return _to_report(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def list_ai_plan_allocations(
|
||||||
|
*,
|
||||||
|
ledger_path: Path | None = None,
|
||||||
|
) -> list[ReportingAllocationReport]:
|
||||||
|
ledger = ledger_path or default_ledger_path()
|
||||||
|
with _connect(ledger) as conn:
|
||||||
|
_ensure_reporting_allocation_schema(conn)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT payload_json FROM reporting_allocations "
|
||||||
|
"WHERE is_current = 1 ORDER BY record_id"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
_to_report(ReportingAllocation.model_validate_json(row["payload_json"]))
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
47
tests/fixtures/session-tokens-two-workplans.json
vendored
Normal file
47
tests/fixtures/session-tokens-two-workplans.json
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"schema_version": "0.1",
|
||||||
|
"record_type": "session_token_evidence",
|
||||||
|
"record_id": "session-tokens:state-hub:2026-08:two-workplans",
|
||||||
|
"revision_of": null,
|
||||||
|
"source_system": "state-hub",
|
||||||
|
"period_start": "2026-08-01",
|
||||||
|
"period_end": "2026-08-31",
|
||||||
|
"captured_at": "2026-08-15T12:00:00Z",
|
||||||
|
"source_evidence": [
|
||||||
|
"state-hub:/token-events/aggregate/?month=2026-08"
|
||||||
|
],
|
||||||
|
"totals": {
|
||||||
|
"measured": {
|
||||||
|
"tokens_in": 200,
|
||||||
|
"tokens_out": 0,
|
||||||
|
"event_count": 3,
|
||||||
|
"confidence": "1.0"
|
||||||
|
},
|
||||||
|
"estimated": {
|
||||||
|
"tokens_in": 50,
|
||||||
|
"tokens_out": 0,
|
||||||
|
"event_count": 1,
|
||||||
|
"confidence": "0.35"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"associations": [
|
||||||
|
{
|
||||||
|
"scope": "workplan",
|
||||||
|
"scope_id": "workplan-a",
|
||||||
|
"label": "WP-A",
|
||||||
|
"measurement_kind": "measured",
|
||||||
|
"tokens_in": 100,
|
||||||
|
"tokens_out": 0,
|
||||||
|
"event_count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": "workplan",
|
||||||
|
"scope_id": "workplan-b",
|
||||||
|
"label": "WP-B",
|
||||||
|
"measurement_kind": "measured",
|
||||||
|
"tokens_in": 50,
|
||||||
|
"tokens_out": 0,
|
||||||
|
"event_count": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
98
tests/test_reporting_allocation.py
Normal file
98
tests/test_reporting_allocation.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import sqlite3
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fin_hub.services.allocation import shared_cost_allocations
|
||||||
|
from fin_hub.services.exchange import ingest_planning_evidence
|
||||||
|
from fin_hub.services.ledger import import_csv, monthly_summary
|
||||||
|
from fin_hub.services.reporting_allocation import (
|
||||||
|
allocate_ai_plan_report,
|
||||||
|
list_ai_plan_allocations,
|
||||||
|
)
|
||||||
|
from fin_hub.services.session_tokens import ingest_session_token_file
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_fact(tmp_path: Path) -> tuple[Path, str]:
|
||||||
|
ledger = tmp_path / "ledger.db"
|
||||||
|
import_csv(FIXTURES / "ai-plans.csv", "ai-plan", ledger_path=ledger)
|
||||||
|
with sqlite3.connect(ledger) as conn:
|
||||||
|
fact_id = conn.execute(
|
||||||
|
"SELECT financial_fact_id FROM ledger_entries "
|
||||||
|
"WHERE source_type = 'ai-plan' AND label = 'claude-max' AND is_current = 1"
|
||||||
|
).fetchone()[0]
|
||||||
|
return ledger, fact_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_measured_token_share_two_workplans_keeps_unmeasured_residual(tmp_path: Path):
|
||||||
|
ledger, fact_id = _claude_fact(tmp_path)
|
||||||
|
ingest_session_token_file(
|
||||||
|
FIXTURES / "session-tokens-two-workplans.json", ledger_path=ledger
|
||||||
|
)
|
||||||
|
report = allocate_ai_plan_report(
|
||||||
|
fact_id, method="measured_token_share", ledger_path=ledger
|
||||||
|
)
|
||||||
|
by_key = {target.target_key: target for target in report.targets}
|
||||||
|
assert report.booked_amount == Decimal("200.00")
|
||||||
|
assert report.method == "measured_token_share"
|
||||||
|
assert report.residual_reason == "unmeasured"
|
||||||
|
assert report.residual_share == Decimal("0.25")
|
||||||
|
assert report.residual_amount == Decimal("50.00")
|
||||||
|
assert by_key["workplan:workplan-a"].amount == Decimal("100.00")
|
||||||
|
assert by_key["workplan:workplan-b"].amount == Decimal("50.00")
|
||||||
|
assert sum((target.amount for target in report.targets), report.residual_amount) == Decimal(
|
||||||
|
"200.00"
|
||||||
|
)
|
||||||
|
assert monthly_summary(ledger_path=ledger)[0].total == pytest.approx(240.0)
|
||||||
|
assert shared_cost_allocations(ledger_path=ledger) == []
|
||||||
|
assert list_ai_plan_allocations(ledger_path=ledger)[0].record_id == report.record_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_even_split_does_not_use_token_weights(tmp_path: Path):
|
||||||
|
ledger, fact_id = _claude_fact(tmp_path)
|
||||||
|
ingest_session_token_file(
|
||||||
|
FIXTURES / "session-tokens-two-workplans.json", ledger_path=ledger
|
||||||
|
)
|
||||||
|
report = allocate_ai_plan_report(fact_id, method="even_split", ledger_path=ledger)
|
||||||
|
amounts = sorted(target.amount for target in report.targets)
|
||||||
|
assert amounts == [Decimal("100.00"), Decimal("100.00")]
|
||||||
|
assert report.residual_share == Decimal("0.00")
|
||||||
|
assert report.residual_reason == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reporting_allocation_is_not_planning_allocation(tmp_path: Path):
|
||||||
|
ledger, fact_id = _claude_fact(tmp_path)
|
||||||
|
report = allocate_ai_plan_report(fact_id, method="even_split", ledger_path=ledger)
|
||||||
|
with pytest.raises(ValueError, match="not resource-control"):
|
||||||
|
ingest_planning_evidence(
|
||||||
|
{
|
||||||
|
"record_type": "reporting_allocation",
|
||||||
|
"record_id": report.record_id,
|
||||||
|
"financial_fact_id": fact_id,
|
||||||
|
"method": "even_split",
|
||||||
|
},
|
||||||
|
ledger_path=ledger,
|
||||||
|
)
|
||||||
|
with sqlite3.connect(ledger) as conn:
|
||||||
|
planning = conn.execute(
|
||||||
|
"SELECT count(*) FROM sqlite_master WHERE name = 'planning_evidence'"
|
||||||
|
).fetchone()[0]
|
||||||
|
if planning:
|
||||||
|
stored = conn.execute(
|
||||||
|
"SELECT record_type FROM planning_evidence"
|
||||||
|
).fetchall()
|
||||||
|
assert stored == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_ai_plan_fact_is_rejected(tmp_path: Path):
|
||||||
|
ledger = tmp_path / "ledger.db"
|
||||||
|
import_csv(FIXTURES / "cloud-costs.csv", "cloud", ledger_path=ledger)
|
||||||
|
with sqlite3.connect(ledger) as conn:
|
||||||
|
fact_id = conn.execute(
|
||||||
|
"SELECT financial_fact_id FROM ledger_entries WHERE is_current = 1"
|
||||||
|
).fetchone()[0]
|
||||||
|
with pytest.raises(ValueError, match="ai-plan"):
|
||||||
|
allocate_ai_plan_report(fact_id, method="even_split", ledger_path=ledger)
|
||||||
|
|
@ -275,7 +275,7 @@ slices require a method. No session files are parsed and no
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: FIN-WP-0007-T05
|
id: FIN-WP-0007-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "db098143-90b7-475c-8846-e4c274e50b4a"
|
state_hub_task_id: "db098143-90b7-475c-8846-e4c274e50b4a"
|
||||||
```
|
```
|
||||||
|
|
@ -299,6 +299,12 @@ Done when a one-plan, two-workplan fixture reconciles to the booked
|
||||||
effective amount, names its method, keeps residual when coverage is
|
effective amount, names its method, keeps residual when coverage is
|
||||||
incomplete, and is not stored as `AllocationEvidence`.
|
incomplete, and is not stored as `AllocationEvidence`.
|
||||||
|
|
||||||
|
Completed 2026-08-15: `ledger allocate-plan` writes a reporting
|
||||||
|
allocation for an `ai-plan` fact. One plan / two workplans at 100 and
|
||||||
|
50 measured tokens of a 200-token pool leaves a 0.25 unmeasured
|
||||||
|
residual and still sums to the booked €200. The record is not stored
|
||||||
|
as `AllocationEvidence` and does not change booked totals.
|
||||||
|
|
||||||
## Publish an effectiveness report
|
## Publish an effectiveness report
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue