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:
tegwick 2026-08-15 19:35:43 +02:00
parent 8916f3c4d0
commit efd1b31001
10 changed files with 646 additions and 3 deletions

View file

@ -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)

View file

@ -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

View file

@ -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",

View 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

View file

@ -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:

View file

@ -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)")}

View 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
]

View 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
}
]
}

View 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)

View file

@ -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