Implement RESOURCE-WP-0005: entity register, V0.1 terms parameters, entity association on inventory and planning records, transfer-price and credit-state arithmetic, monthly settlement, and entity views on the portfolio report. Live close emits nothing until delivered cost is known. Handoffs are FIN-WP-0006 and RAILIANCE-WP-0017.
354 lines
14 KiB
Python
354 lines
14 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 entities import REQUIRED_ENTITY_IDS, load_register
|
|
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",
|
|
"storage": "storage_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"]
|
|
],
|
|
# A delivered gap stays visible with its residual unknowns rather than
|
|
# disappearing, so partial delivery is not read as full coverage.
|
|
"unresolved_gaps": [
|
|
{"owner": gap["owner"], "gap": gap["gap"], "delegated_workplan": gap["delegated_workplan"]}
|
|
for gap in coverage["owned_gaps"]
|
|
if gap.get("status", "open") == "open"
|
|
],
|
|
"delivered_gaps": [
|
|
{
|
|
"owner": gap["owner"],
|
|
"delegated_workplan": gap["delegated_workplan"],
|
|
"delivered_on": gap.get("delivered_on"),
|
|
"interface": gap.get("interface", []),
|
|
"residual_unknowns": gap.get("residual_unknowns", []),
|
|
}
|
|
for gap in coverage["owned_gaps"]
|
|
if gap.get("status", "open") == "delivered"
|
|
],
|
|
}
|
|
|
|
|
|
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 entities_section(resources: list[dict], register: dict[str, dict]) -> dict:
|
|
by_id = {entity_id: {
|
|
"financial_entity_id": entity_id,
|
|
"display_name": register[entity_id]["display_name"],
|
|
"role": register[entity_id]["role"],
|
|
"dedicated_resources": [],
|
|
"shared_shares": [],
|
|
"priced_resources": 0,
|
|
"consumption_mode": None,
|
|
"consumption_mode_note": "no settlement close yet; mode is unknown, not open",
|
|
} for entity_id in REQUIRED_ENTITY_IDS}
|
|
unattributed = []
|
|
for resource in resources:
|
|
entity_id = resource.get("financial_entity_id")
|
|
entry = {
|
|
"resource_id": resource["id"],
|
|
"price_evidence": bool(resource["cost"]["price_evidence"]),
|
|
}
|
|
if entity_id:
|
|
by_id[entity_id]["dedicated_resources"].append(resource["id"])
|
|
if resource["cost"]["price_evidence"]:
|
|
by_id[entity_id]["priced_resources"] += 1
|
|
else:
|
|
unattributed.append({
|
|
"resource_id": resource["id"],
|
|
"entity_gap": resource.get("entity_gap"),
|
|
"allocation_mode": resource["ownership"]["allocation"]["mode"],
|
|
})
|
|
for share in resource["ownership"]["allocation"].get("entity_shares") or []:
|
|
share_id = share["financial_entity_id"]
|
|
by_id[share_id]["shared_shares"].append({
|
|
"resource_id": resource["id"],
|
|
"note": share["note"],
|
|
})
|
|
return {
|
|
"entities": [by_id[entity_id] for entity_id in REQUIRED_ENTITY_IDS],
|
|
"unattributed_resources": unattributed,
|
|
"known_monthly_spend_eur": None,
|
|
}
|
|
|
|
|
|
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']}")
|
|
for gap in report["coverage"].get("delivered_gaps", []):
|
|
for residual in gap["residual_unknowns"]:
|
|
actions.append(f"{gap['owner']}: {gap['delegated_workplan']} delivered, still open — {residual}")
|
|
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)
|
|
_, register = load_register(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,
|
|
"entities": entities_section(resources, register),
|
|
"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())
|