resource-control/tools/portfolio_report.py
tegwick 2c2a6073ff feat(portfolio): complete RESOURCE-WP-0003 T06 optimization cases and T07 reporting
T06: optimization-case schema, fail-closed evaluator, cadence and decision
template. Every option including the baseline must present all ten decision
fields; one unknown blocks the comparison. Validated on the storage case
(Hetzner computes and loses to Scaleway by EUR 29.14/month on operator labour;
Host Europe blocks on four named gaps) and on the non-storage reef-railiance
k3s rightsizing case (low utilization is real, but nothing is costable while
the railiance01 price is unknown).

T07: portfolio report over coverage, lifecycle, utilization, cost, renewals,
risks, open cases, and next actions, derived only from committed evidence.
Portfolio spend is reported null rather than as a partial sum, unattributed
cost is a named list rather than a spread, and unmeasurable resources are
reported rather than dropped.

RESOURCE-WP-0003 is finished; both cases remain blocked_on_evidence against
live delegated records in other repositories. RESOURCE-WP-0002 is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 09:28:44 +02:00

292 lines
11 KiB
Python

#!/usr/bin/env python3
"""Portfolio view: coverage, lifecycle, capacity, cost, renewals, risk, and openings.
The report never fills a gap with a guess. Every section separates what is known
from what is unknown, names the owner of each unknown, and reports unattributed
cost as its own figure rather than spreading it across resources.
"""
from __future__ import annotations
import json
import sys
from datetime import date
from pathlib import Path
from optimization import evaluate, validate_case
from portfolio import validate_record
# How far ahead a contract date counts as "approaching".
RENEWAL_HORIZON_DAYS = 90
# Utilization at or below this fraction is flagged as idle capacity.
IDLE_THRESHOLD = 0.35
# Utilization at or above this fraction is flagged as saturated.
SATURATED_THRESHOLD = 0.85
USAGE_PAIRS = {
"cpu": "cpu_usage",
"memory": "memory_usage",
"root_filesystem": "root_filesystem_used",
}
def _load_all(pattern: Path) -> list[dict]:
return [json.loads(path.read_text()) for path in sorted(pattern.parent.glob(pattern.name))]
def load_portfolio(root: Path) -> dict:
resources = _load_all(root / "data" / "resources" / "*.json")
for resource in resources:
validate_record(resource)
coverage_files = sorted((root / "data").glob("portfolio-coverage-*.json"))
coverage = json.loads(coverage_files[-1].read_text()) if coverage_files else None
cases = _load_all(root / "data" / "optimization" / "*.json")
for case in cases:
validate_case(case)
return {"resources": resources, "coverage": coverage, "cases": cases}
def coverage_section(coverage: dict | None) -> dict:
if coverage is None:
return {"observed_at": None, "groups": [], "unresolved_gaps": [], "note": "no coverage observation recorded"}
return {
"observed_at": coverage["observed_at"],
"inventory_records": coverage["inventory_records"],
"groups": [
{"group": group["group"], "status": group["status"], "resources": len(group["resource_ids"])}
for group in coverage["coverage"]
],
"unresolved_gaps": [
{"owner": gap["owner"], "gap": gap["gap"], "delegated_workplan": gap["delegated_workplan"]}
for gap in coverage["owned_gaps"]
],
}
def lifecycle_section(resources: list[dict]) -> dict:
counts: dict[str, int] = {}
for resource in resources:
counts[resource["status"]] = counts.get(resource["status"], 0) + 1
unowned = [r["id"] for r in resources if not r["ownership"]["owner"]]
return {"by_status": dict(sorted(counts.items())), "without_owner": unowned}
def _capacity_index(resource: dict) -> dict:
return {entry["metric"]: entry for entry in resource["capacity"]}
def utilization_section(resources: list[dict]) -> dict:
measured, unmeasured = [], []
for resource in resources:
metrics = _capacity_index(resource)
rows = []
for metric, usage_metric in USAGE_PAIRS.items():
if metric not in metrics or usage_metric not in metrics:
continue
provisioned = metrics[metric]["value"]
used = metrics[usage_metric]["value"]
if not provisioned:
continue
ratio = round(used / provisioned, 4)
rows.append({
"metric": metric,
"provisioned": provisioned,
"used": used,
"unit": metrics[metric]["unit"],
"ratio": ratio,
"observed_at": metrics[metric].get("observed_at"),
"signal": (
"idle" if ratio <= IDLE_THRESHOLD
else "saturated" if ratio >= SATURATED_THRESHOLD
else "normal"
),
})
if rows:
measured.append({"resource_id": resource["id"], "metrics": rows})
else:
unmeasured.append({
"resource_id": resource["id"],
"owner": resource["ownership"]["owner"],
"reason": "no paired provisioned and observed capacity metric",
})
return {
"measured": measured,
"unmeasured": unmeasured,
"idle": sorted({
row["resource_id"] for row in measured
for metric in row["metrics"] if metric["signal"] == "idle"
}),
"saturated": sorted({
row["resource_id"] for row in measured
for metric in row["metrics"] if metric["signal"] == "saturated"
}),
}
def cost_section(resources: list[dict]) -> dict:
priced, unpriced, unattributed = [], [], []
for resource in resources:
entry = {
"resource_id": resource["id"],
"owner": resource["ownership"]["owner"],
"billing_model": resource["cost"]["billing_model"],
}
if resource["cost"]["price_evidence"]:
priced.append({**entry, "price_evidence": resource["cost"]["price_evidence"]})
else:
unpriced.append(entry)
if resource["ownership"]["allocation"]["mode"] == "unattributed":
unattributed.append(entry)
return {
"priced": priced,
"unpriced": unpriced,
"unattributed_allocation": unattributed,
# Deliberately not a number: no booked cost has reached this repository,
# so any portfolio spend total would be invented rather than measured.
"known_monthly_spend_eur": None,
"spend_note": (
f"{len(priced)} of {len(resources)} resources carry price evidence and none carries a booked "
"cost from fin-hub, so total portfolio spend is not computable and is reported as unknown "
"rather than as zero."
),
}
def renewals_section(resources: list[dict], today: date) -> dict:
approaching, undated = [], []
for resource in resources:
lifecycle = resource["lifecycle"]
dates = {k: lifecycle[k] for k in ("renews_on", "cancel_by") if lifecycle[k]}
if not dates:
if resource["status"] in {"active", "ordered", "commissioning"}:
undated.append({
"resource_id": resource["id"],
"owner": resource["ownership"]["owner"],
"risk": "no renewal or cancellation date recorded; the cancellation window cannot be respected",
})
continue
for field, value in dates.items():
days = (date.fromisoformat(value) - today).days
if days <= RENEWAL_HORIZON_DAYS:
approaching.append({
"resource_id": resource["id"],
"field": field,
"date": value,
"days_remaining": days,
})
approaching.sort(key=lambda row: row["days_remaining"])
return {"horizon_days": RENEWAL_HORIZON_DAYS, "approaching": approaching, "undated": undated}
def risk_section(resources: list[dict], utilization: dict, cost: dict) -> list[dict]:
risks = []
domains: dict[str, list[str]] = {}
for resource in resources:
for domain in resource["location"]["failure_domains"]:
domains.setdefault(domain, []).append(resource["id"])
for domain, members in sorted(domains.items()):
if len(members) > 2:
risks.append({
"kind": "concentrated_failure_domain",
"detail": f"{len(members)} resources share {domain}",
"resource_ids": sorted(members),
})
if cost["unpriced"]:
risks.append({
"kind": "unpriced_resources",
"detail": f"{len(cost['unpriced'])} resources have no price evidence, so spend cannot be measured",
"resource_ids": sorted(row["resource_id"] for row in cost["unpriced"]),
})
if cost["unattributed_allocation"]:
risks.append({
"kind": "unattributed_cost",
"detail": f"{len(cost['unattributed_allocation'])} resources have no allocation method, so their cost reaches no consumer",
"resource_ids": sorted(row["resource_id"] for row in cost["unattributed_allocation"]),
})
if utilization["idle"]:
risks.append({
"kind": "idle_capacity",
"detail": f"utilization at or below {IDLE_THRESHOLD:.0%} of provisioned capacity",
"resource_ids": utilization["idle"],
})
if utilization["saturated"]:
risks.append({
"kind": "saturated_capacity",
"detail": f"utilization at or above {SATURATED_THRESHOLD:.0%} of provisioned capacity",
"resource_ids": utilization["saturated"],
})
return risks
def optimization_section(cases: list[dict]) -> dict:
open_cases, blocked_on = [], []
for case in cases:
report = evaluate(case)
open_cases.append({
"case_id": report["case_id"],
"case_type": report["case_type"],
"state": report["decision_state"],
"best_option_id": report["best_option_id"],
"verdicts": {c["option_id"]: c["verdict"] for c in report["comparisons"]},
})
for comparison in report["comparisons"]:
for item in comparison["blocking_evidence"]:
if ".unknown:" in item:
blocked_on.append({"case_id": report["case_id"], "missing": item.split(".unknown:", 1)[1]})
return {
"cases": open_cases,
"undecided": [c["case_id"] for c in open_cases if c["state"] in {"blocked_on_evidence", "proposed"}],
"blocked_on_evidence": blocked_on,
}
def next_actions(report: dict) -> list[str]:
"""The smallest set of evidence that would unblock the most decisions."""
actions = []
for gap in report["coverage"]["unresolved_gaps"]:
actions.append(f"{gap['owner']}: deliver {gap['delegated_workplan']}{gap['gap']}")
if report["cost"]["unpriced"]:
actions.append(
"resource-control: no portfolio spend figure exists until at least one booked cost arrives "
"from fin-hub under the exchange contract"
)
for row in report["renewals"]["undated"]:
actions.append(f"{row['owner']}: record renewal and cancellation dates for {row['resource_id']}")
return actions
def build(root: Path, today: date | None = None) -> dict:
today = today or date.today()
data = load_portfolio(root)
resources = data["resources"]
utilization = utilization_section(resources)
cost = cost_section(resources)
report = {
"schema_version": "0.1",
"generated_on": today.isoformat(),
"resource_count": len(resources),
"coverage": coverage_section(data["coverage"]),
"lifecycle": lifecycle_section(resources),
"utilization": utilization,
"cost": cost,
"renewals": renewals_section(resources, today),
"optimization": optimization_section(data["cases"]),
}
report["risks"] = risk_section(resources, utilization, cost)
report["next_actions"] = next_actions(report)
return report
def main() -> int:
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
print(json.dumps(build(root), indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())