diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index d9ac00a..8c6b1e4 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -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) diff --git a/README.md b/README.md index 5a1e45f..f86c494 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/fin_hub/cli.py b/src/fin_hub/cli.py index a411eb2..36f7bf7 100644 --- a/src/fin_hub/cli.py +++ b/src/fin_hub/cli.py @@ -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", diff --git a/src/fin_hub/schemas/reporting_allocation.py b/src/fin_hub/schemas/reporting_allocation.py new file mode 100644 index 0000000..fce0cdf --- /dev/null +++ b/src/fin_hub/schemas/reporting_allocation.py @@ -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 diff --git a/src/fin_hub/services/exchange.py b/src/fin_hub/services/exchange.py index 90e5077..080768a 100644 --- a/src/fin_hub/services/exchange.py +++ b/src/fin_hub/services/exchange.py @@ -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: diff --git a/src/fin_hub/services/ledger.py b/src/fin_hub/services/ledger.py index 8cdca2e..6dfb20a 100644 --- a/src/fin_hub/services/ledger.py +++ b/src/fin_hub/services/ledger.py @@ -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)")} diff --git a/src/fin_hub/services/reporting_allocation.py b/src/fin_hub/services/reporting_allocation.py new file mode 100644 index 0000000..ff87689 --- /dev/null +++ b/src/fin_hub/services/reporting_allocation.py @@ -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 + ] diff --git a/tests/fixtures/session-tokens-two-workplans.json b/tests/fixtures/session-tokens-two-workplans.json new file mode 100644 index 0000000..8c96760 --- /dev/null +++ b/tests/fixtures/session-tokens-two-workplans.json @@ -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 + } + ] +} diff --git a/tests/test_reporting_allocation.py b/tests/test_reporting_allocation.py new file mode 100644 index 0000000..4d2ca0f --- /dev/null +++ b/tests/test_reporting_allocation.py @@ -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) diff --git a/workplans/FIN-WP-0007-ai-plan-token-effectiveness.md b/workplans/FIN-WP-0007-ai-plan-token-effectiveness.md index 6b407eb..487ee19 100644 --- a/workplans/FIN-WP-0007-ai-plan-token-effectiveness.md +++ b/workplans/FIN-WP-0007-ai-plan-token-effectiveness.md @@ -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