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 ingest-session-tokens tests/fixtures/session-tokens-2026-08.json
|
||||
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
|
||||
|
||||
# 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 ingest-session-tokens tests/fixtures/session-tokens-2026-08.json
|
||||
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 margins
|
||||
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.billing import build_billing_basis
|
||||
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 (
|
||||
ingest_session_token_file,
|
||||
list_current_session_token_evidence,
|
||||
|
|
@ -231,6 +235,25 @@ def _cmd_ledger_session_tokens(args: argparse.Namespace) -> int:
|
|||
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:
|
||||
reports = shared_cost_allocations(ledger_path=_ledger_path(args))
|
||||
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.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(
|
||||
"allocations",
|
||||
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:
|
||||
"""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(
|
||||
"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()
|
||||
with _connect(ledger) as conn:
|
||||
|
|
|
|||
|
|
@ -286,6 +286,17 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|||
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)")}
|
||||
|
|
|
|||
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
|
||||
id: FIN-WP-0007-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
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
|
||||
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
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue