diff --git a/CHANGELOG.md b/CHANGELOG.md index 75bfea13..b1244aee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — Portfolio risk heat map + +`GET /portfolio/risk` (`risk_portfolio.py`) grids `risk_board` across the book: projects down, the +five risk engines across (Monte-Carlo schedule risk · predictive alerts · EVM · pre-flight gate · +overdue coordination), intensity `3·high + 2·medium + 1·low`. Rendered on Portfolio beside the +executive roll-up, with a "worst first" line and click-through to the project. + +`/portfolio/executive` and `/portfolio/construction` roll up *performance*; neither could say which +risk **engine** is hot on which project. Cells come from `risk_board.board` unchanged — same engines, +same Monte-Carlo seed — so a cell and the project's own risk panel cannot disagree. That costs a full +board per project, so the sweep is bounded by `limit` (default 25, clamped 1–100) and reports +`truncated` rather than quietly scanning a prefix. + +**An empty cell is not a safe cell.** A grid of counts renders `0` for two different facts: *this +engine looked and found nothing*, and *this engine could not run*. `board` is fail-open per lane and +already separates them, so every cell carries a `state`; an unmeasured cell carries **no counts at +all** rather than zeros, and the UI draws it as a dash. `coverage` reports the split at portfolio +level. A clear signal nobody has a basis for is worse than no heat map. + +**`risk_board.LANES` is new, and gated against a real board run.** `board` reports coverage under +lane keys (`schedule_risk`) while its items carry source strings (`schedule-risk`); nothing connected +the two, and a roll-up must join on both. `test_risk_portfolio.py` asserts every lane key `board` +emits appears in the table and every `source` its items carry is a value — so a lane added to `board` +alone fails rather than rendering as a column that never lights up. + +Both claims mutation-checked: emitting zeros for an error cell, and dropping a lane from `LANES`, +each fail with the shape named. + ## Unreleased — SCALE-SEAM (101): design-phase predicted performance Six methods out of `client.ts` (**642 → 603**). Five to a new diff --git a/apps/web/src/api/risk.ts b/apps/web/src/api/risk.ts index 1ed46f34..b23c2be5 100644 --- a/apps/web/src/api/risk.ts +++ b/apps/web/src/api/risk.ts @@ -25,6 +25,27 @@ export function withRisk>(Base: TBase) { riskDigest(pid: string) { return this.json(`/projects/${pid}/risk-digest`); } + /** The same board gridded across the portfolio — projects × risk engine, severity-weighted. + * `state` on a cell is not decoration: an engine that could not run reads `error`, and the map + * must not render that as a clear cell. `coverage` says how much of the grid is measured. */ + portfolioRisk(limit = 25) { + return this.json<{ + projects: { id: string; name: string; band: string | null; score: number; count: number; + high: number; medium: number; low: number; measured_sources: number; + cells: Record }[]; + sources: { key: string; label: string; score: number; count: number; high: number; + projects_measured: number; projects_error: number }[]; + totals: { high: number; medium: number; low: number; count: number; score: number }; + band_tally: Record; + hotspots: { project_id: string; project: string; source: string; score: number; high: number; + title: string | null; link: string | null }[]; + coverage: { cells: number; measured: number; errored: number; unknown: number; + pct: number | null }; + project_count: number; projects_available: number; truncated: boolean; limit: number; + note: string; + }>(`/portfolio/risk?limit=${limit}`); + } /** RISK-BOARD: one ranked register unifying every computed risk signal (deep-linked per item). */ riskBoard(pid: string) { return this.json<{ items: { source: string; severity: "high" | "medium" | "low"; title: string; diff --git a/apps/web/src/portal/panels/portfolio.ts b/apps/web/src/portal/panels/portfolio.ts index e8a548db..c189afe2 100644 --- a/apps/web/src/portal/panels/portfolio.ts +++ b/apps/web/src/portal/panels/portfolio.ts @@ -142,6 +142,73 @@ export async function renderPortfolio(ctx: PanelContext) { ctx.root.appendChild(card); }).catch(() => { /* returns spread is best-effort; the roll-up above stands on its own */ }); + // RISK HEAT MAP — R22-PIPELINE. The table above says how each project is PERFORMING; this says + // which risk ENGINE is hot on which project, which is the question that decides where a + // programme director spends the morning. Cells come from the same `risk_board` each project's + // own risk panel renders, so clicking through can never show a different number. + // + // A cell whose engine could not run is drawn as a dash on the muted ground, never as a green + // zero. That is the whole reason `state` is on the wire: an unmeasured source rendered as clear + // is worse than no heat map, because it is a clear signal nobody has any basis for. + void ctx.host.api.portfolioRisk().then((hm) => { + if (!hm.projects.length) return; + const card = document.createElement("div"); card.className = "dash-card"; card.style.marginTop = "10px"; + const cov = hm.coverage; + card.innerHTML = `Risk heat map ${hm.project_count} project(s) × ${hm.sources.length} engines` + + ` · ${hm.totals.high} high / ${hm.totals.medium} medium / ${hm.totals.low} low` + + (cov.pct == null ? "" : ` · ${cov.pct}% of cells measured`) + + (cov.errored || cov.unknown ? ` · ${cov.errored + cov.unknown} unavailable` : "") + + (hm.truncated ? ` · showing ${hm.project_count} of ${hm.projects_available}` : "") + + ``; + // Intensity ramp, not a gradient: four steps a reader can name, keyed off the same + // severity-weighted score the API computes so the colour and the number never diverge. + const heat = (score: number) => score >= 9 ? "var(--status-crit)" : score >= 4 ? "var(--status-warn)" + : score > 0 ? "var(--status-good)" : "transparent"; + const tbl = document.createElement("table"); tbl.className = "portal-table"; tbl.style.fontSize = "11px"; + tbl.innerHTML = `Project` + + hm.sources.map((s2) => `${esc(s2.label)}`).join("") + + `Total`; + const tb = document.createElement("tbody"); + for (const p of hm.projects) { + const tr = document.createElement("tr"); tr.className = "kpi-click"; + if (p.id === here) tr.style.fontWeight = "700"; + const cells = hm.sources.map((s2) => { + const c = p.cells[s2.key]; + if (!c || c.state !== "ok") { + return `–`; + } + const col = heat(c.score ?? 0); + const txt = c.count ? String(c.count) : "·"; + return `${txt}`; + }).join(""); + tr.innerHTML = `${esc(p.name)}${p.id === here ? " ·" : ""}${cells}` + + `${p.count || "—"}`; + // Keyboard-operable, matching the `documents.ts` folder-row idiom. `.kpi-click` already + // styles `:focus-visible` (style.css) — the stylesheet was written expecting these rows to + // be focusable, and a pointer-only handler quietly never delivered it. + const go = () => { if (p.id !== here) window.location.search = `?project=${p.id}`; }; + if (p.id !== here) { + tr.setAttribute("role", "button"); tr.tabIndex = 0; + tr.setAttribute("aria-label", `Open ${p.name}, ${p.count} open risk item(s)`); + tr.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); } }; + } + tr.onclick = go; + tb.appendChild(tr); + } + tbl.appendChild(tb); card.appendChild(tbl); + if (hm.hotspots.length) { + const hl = document.createElement("div"); hl.className = "meta"; hl.style.marginTop = "6px"; + hl.innerHTML = "Worst first: " + hm.hotspots.slice(0, 4) + .map((x) => `${esc(x.project)} — ${esc(x.title ?? x.source)}`).join(" · "); + card.appendChild(hl); + } + card.appendChild(Object.assign(document.createElement("div"), { className: "meta", + textContent: "Cell = open risk items from that engine (hover for the severity split); " + + "a dash means the engine could not run for that project, which is not the same as clear." })); + ctx.root.appendChild(card); + }).catch(() => { /* heat map is best-effort; the roll-up above stands on its own */ }); + // Acquisition funnel sits ABOVE the construction book: executive KPIs answer deals we already // won; this answers what is in the book, how much of it historically closes, and how long it // takes. Weighted value uses this firm's closed history — a stage without enough samples is diff --git a/docs/roadmap.md b/docs/roadmap.md index e562a8a1..7c2243d6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1860,6 +1860,32 @@ stakes we are missing. ✅ **Funnel viz SHIPPED v0.3.1135.** `GET /pipeline/funnel` had no caller; Portfolio now renders stage counts, derived win rates, weighted value with coverage, and closed cycle time beside open age. Cross-project Gantt, risk heat map, and department resourcing remain. + + ✅ **Risk heat map SHIPPED — `GET /portfolio/risk`, `services/api/src/aec_api/risk_portfolio.py`.** + Projects down, the five `risk_board` engines across, intensity `3·high + 2·medium + 1·low`. Cells + come from `risk_board.board` unchanged — same engines, same Monte-Carlo seed — so a cell and the + project's own risk panel cannot disagree; that costs a full board per project, which is why the + sweep is bounded by `limit` and reports `truncated` rather than silently scanning a prefix. + + **The design decision worth recording is that an empty cell is not a safe cell.** A grid of counts + renders `0` for two different facts — *this engine looked and found nothing* and *this engine could + not run*. `board` already separates them (it is fail-open per lane and returns `lanes: {name: ok | + error}` beside its items), so every cell carries a `state` and an unmeasured one carries **no counts + at all** rather than zeros; the UI draws it as a dash, never a green zero. This is the cap-table + lesson in a second place — *do not let an unmeasured value wear the costume of a measured one* — + and it is mutation-checked: making the error branch emit zeros fails + `services/api/test_risk_portfolio.py` on the cell shape. + + **A second gate came out of building it.** `board` reports coverage under lane keys + (`schedule_risk`) while its items carry source strings (`schedule-risk`), and nothing connected the + two — a roll-up has to join on both. The pairing is now `risk_board.LANES`, asserted against a + **real board run**: every lane key `board` emits must appear, every `source` its items carry must be + a value. A lane added to `board` and not to `LANES` would otherwise render as a column that + silently never lights up. + + **Still open: cross-project Gantt and department resourcing.** The resourcing half is not the small + item this entry's phrasing suggests — `resource_loading.py` groups by **trade and resource type**, + per project, so "by department" needs both a new dimension and a portfolio axis. Size it on its own. ## ⚡ R23 — ENGINEERING UPGRADE RING *(technical scan 2026-07-25; file:line evidence)* **A THIRD false blocker, and the biggest one.** **W10-9 dimensional constraints** has sat gated for diff --git a/services/api/run_tests.py b/services/api/run_tests.py index cb260624..f49dd21b 100644 --- a/services/api/run_tests.py +++ b/services/api/run_tests.py @@ -86,7 +86,7 @@ "test_markup", "test_route_authz", "test_resource_id_authz", "test_route_reachability", "test_resumable_upload", "test_model_align", "test_ifc_parse_gate", "test_plugin_isolation", "test_body_pid_authz", "test_global_authz", "test_protected_prefix_coverage", "test_baseline", "test_global_mutating_authz", "test_ref_counter", "test_audit_coverage", "test_bsdd", "test_openbim_registry", "test_waterfall", "test_waterfall_cents", "test_sessions", "test_mfa", "test_stored_ids", "test_cobie", "test_fts_index", "test_scim", "test_scim_provision_race", "test_saml", "test_responsibility", "test_array_live", "test_assemblies", "test_dxf_takeoff", "test_qto_class_match", "test_georef", "test_scene_package", "test_clash_bvh", "test_model_qa", "test_model_health", "test_roundtrip_qa", "test_stakeholder", "test_prioritization", "test_ai_readiness", - "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_cap_table_state", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", + "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_risk_portfolio", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_cap_table_state", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", # R41-TEST-RESIDUE — the residue sweep must never propose a database it does not own: "test_sweep_guard", # R23-DIGEST — the deterministic model digest and its two routes: diff --git a/services/api/src/aec_api/risk_board.py b/services/api/src/aec_api/risk_board.py index c491ca30..2072c85b 100644 --- a/services/api/src/aec_api/risk_board.py +++ b/services/api/src/aec_api/risk_board.py @@ -14,6 +14,21 @@ _SEV_ORDER = {"high": 0, "medium": 1, "low": 2} +# The lane key `board` reports coverage under, the `source` string its items carry, and a display +# label. Two names for one thing is a drift hazard — the lane is `schedule_risk` while its items say +# `schedule-risk`, and nothing but this table connects them. A roll-up over several projects has to +# join on both (coverage from `lanes`, counts from `items`), so the pairing is stated once here +# rather than re-guessed by every consumer. `test_risk_portfolio.py` asserts it against a REAL board +# run — every lane key `board` emits appears here, and every `source` its items carry is a value — +# so widening `board` without widening this table fails rather than silently dropping a column. +LANES: tuple[tuple[str, str, str], ...] = ( + ("schedule_risk", "schedule-risk", "Schedule risk"), + ("schedule_alerts", "schedule-alert", "Schedule alerts"), + ("evm", "evm", "EVM"), + ("preflight", "preflight", "Pre-flight"), + ("coordination", "coordination", "Coordination"), +) + def board(db, pid: str) -> dict[str, Any]: from . import modules as me diff --git a/services/api/src/aec_api/risk_portfolio.py b/services/api/src/aec_api/risk_portfolio.py new file mode 100644 index 00000000..2a89cd03 --- /dev/null +++ b/services/api/src/aec_api/risk_portfolio.py @@ -0,0 +1,170 @@ +"""RISK-PORTFOLIO — the portfolio **risk heat map**, one of the three items R22-PIPELINE's +premise-check found genuinely missing. + +`risk_board` answers "what is threatening THIS project" by re-deriving five engines (Monte-Carlo +schedule risk · predictive alerts · EVM · the pre-flight issuance gate · overdue coordination) into +one ranked register. Above the project workspace nobody has that view: `/portfolio/executive` and +`/portfolio/construction` roll up *performance* (SPI, CPI, variance, incident counts), and neither +can say **which risk ENGINE is hot on which project** — the question a heat map exists to answer. +This grids the same board across the book: projects down, risk sources across, severity-weighted +intensity in the cell. + +## An empty cell is not a safe cell + +The one design decision worth stating. A heat map made of counts renders "0" for two entirely +different facts: *this engine looked and found nothing* and *this engine could not run*. `board` +already separates them — it returns `lanes: {name: ok | error}` beside its items, because every lane +is fail-open and a broken source drops its lane rather than the board. So every cell here carries a +`state`, and a cell whose lane errored is `state: "error"` with **no counts at all** rather than +zeros. A blank column that reads as "clear" across the whole portfolio is the exact failure a risk +tool must not have: it is the same class of defect as a cap table that read a stamped default state +as a decision, and the same remedy — *do not let an unmeasured value wear the costume of a measured +one.* + +`coverage` reports the split at portfolio level, so a reader can see how much of the map is real. + +## Weighting + +Intensity is `3·high + 2·medium + 1·low`. Deliberately shallow: the severities come from thresholds +the individual engines already chose, and a steeper curve here would re-weight their judgement +invisibly. The raw counts travel beside the score so a UI can colour on either. + +## Consistency over speed + +Each project's cells come from `risk_board.board` unchanged — same engines, same Monte-Carlo seed — +so a cell here and the project's own risk panel can never disagree. That costs a full board per +project, which is why the sweep is bounded by `limit` and reports `truncated`; the alternative +(cheaper approximations at the portfolio level) is how a dashboard ends up contradicting the panel +it links to. + +The truncation that buys is worth naming rather than hiding: `limit` takes a **deterministic prefix +of the caller's project list**, not the riskiest projects — ranking by risk is exactly what the sweep +computes, so it cannot be used to choose what to sweep. On a book larger than `limit`, the map is a +sample and `truncated` says so; raise `limit` to see the rest. +""" +from __future__ import annotations + +from typing import Any + +from .risk_board import LANES + +_WEIGHT = {"high": 3, "medium": 2, "low": 1} +_BANDS = ("critical", "elevated", "watch", "clear") +DEFAULT_LIMIT = 25 + + +def _empty_counts() -> dict[str, Any]: + """A MEASURED zero — the shape a cell gets only once its lane reported `ok`. An unmeasured + cell never passes through here; see the module note on why that distinction is the point.""" + return {"high": 0, "medium": 0, "low": 0, "count": 0, "score": 0} + + +def _score(c: dict[str, Any]) -> int: + """Cell intensity: `3·high + 2·medium + 1·low`. Shallow on purpose — the severities were + already chosen by the individual engines, and a steeper curve would re-weight them invisibly.""" + return sum(_WEIGHT[s] * c[s] for s in ("high", "medium", "low")) + + +def heatmap(db: Any, projects: list[tuple[str, str]], *, + limit: int = DEFAULT_LIMIT) -> dict[str, Any]: + """Grid `risk_board.board` across `projects` — a list of `(id, name)` already scoped to the + caller. `limit` bounds the sweep (each project is a full board); the rest are reported as + `truncated` rather than silently dropped.""" + from . import risk_board + + scanned = projects[:max(0, int(limit))] + keys = [k for k, _s, _l in LANES] + by_source = {s: k for k, s, _l in LANES} + + rows: list[dict[str, Any]] = [] + src_tot = {k: _empty_counts() | {"projects_measured": 0, "projects_error": 0} for k in keys} + tot = _empty_counts() + tally = dict.fromkeys(_BANDS, 0) + hotspots: list[dict[str, Any]] = [] + measured = errored = unknown = 0 + + for pid, name in scanned: + try: + b = risk_board.board(db, pid) + except Exception: # noqa: BLE001 — a project whose board fails outright still gets a row, + b = None # entirely unmeasured, rather than disappearing from the portfolio. + lanes = (b or {}).get("lanes") or {} + items = (b or {}).get("items") or [] + + cells: dict[str, dict[str, Any]] = {} + worst: dict[str, dict[str, Any]] = {} # lane -> highest-severity item, for the hotspot label + for it in items: + k = by_source.get(it.get("source")) + if k is None: # a source this table does not know: counted at + continue # project level below, never invented as a column + cells.setdefault(k, _empty_counts()) + sev = it.get("severity") + if sev in _WEIGHT: + cells[k][sev] += 1 + cells[k]["count"] += 1 + if k not in worst: + worst[k] = it # board sorts high → low, so the first wins + + row_counts = _empty_counts() + for k in keys: + state = lanes.get(k) + if state == "ok": + c = cells.get(k) or _empty_counts() + c["score"] = _score(c) + c["state"] = "ok" + measured += 1 + src_tot[k]["projects_measured"] += 1 + for f in ("high", "medium", "low", "count", "score"): + src_tot[k][f] += c[f] + row_counts[f] += c[f] + if c["score"]: + w = worst.get(k) or {} + hotspots.append({"project_id": pid, "project": name, "source": k, + "score": c["score"], "high": c["high"], + "title": w.get("title"), "link": w.get("link")}) + else: + # error, or absent because the board itself failed. No counts: see the module note. + c = {"state": "error" if state == "error" else "unknown"} + if state == "error": + errored += 1 + src_tot[k]["projects_error"] += 1 + else: + unknown += 1 + cells[k] = c + + band = (b or {}).get("band") + if band in tally: + tally[band] += 1 + for f in ("high", "medium", "low", "count", "score"): + tot[f] += row_counts[f] + rows.append({"id": pid, "name": name, "band": band, + "measured_sources": sum(1 for k in keys if cells[k].get("state") == "ok"), + **row_counts, "cells": cells}) + + for k in keys: + src_tot[k]["score"] = _score(src_tot[k]) + rows.sort(key=lambda r: (-r["score"], -r["high"], r["name"])) + hotspots.sort(key=lambda h: (-h["score"], -h["high"], h["project"], h["source"])) + cell_total = measured + errored + unknown + labels = {k: lbl for k, _s, lbl in LANES} + return { + "projects": rows, + "sources": [{"key": k, "label": labels[k], **src_tot[k]} for k in keys], + "totals": tot, + "band_tally": tally, + "hotspots": hotspots[:8], + "coverage": {"cells": cell_total, "measured": measured, "errored": errored, + "unknown": unknown, + "pct": round(100.0 * measured / cell_total, 1) if cell_total else None}, + "weights": dict(_WEIGHT), + "project_count": len(scanned), + "projects_available": len(projects), + "truncated": len(projects) > len(scanned), + "limit": limit, + "note": "Projects × risk source, intensity = 3·high + 2·medium + 1·low, every cell from the " + "same board the project's own risk panel shows. A cell reads `error`/`unknown` when " + "its engine could not run — never 0, which would render an unmeasured source as a " + "clear one. `coverage` says how much of the map is measured. When `truncated`, the " + "scanned set is a deterministic prefix of the caller's projects, not the riskiest " + "ones — ranking is what the sweep produces, so it cannot select what to sweep.", + } diff --git a/services/api/src/aec_api/routers/dashboard.py b/services/api/src/aec_api/routers/dashboard.py index b828ba8f..eb55831d 100644 --- a/services/api/src/aec_api/routers/dashboard.py +++ b/services/api/src/aec_api/routers/dashboard.py @@ -144,6 +144,33 @@ def executive_portfolio(db: Session = Depends(get_db), _: str = Depends(rbac.cur return {"projects": rows, "totals": tot, "status_tally": tally, "project_count": len(rows)} +@router.get("/portfolio/risk") +def portfolio_risk(limit: int = 25, db: Session = Depends(get_db), + _: str = Depends(rbac.current_user)): + """R22-PIPELINE — the **portfolio risk heat map**: every accessible project down, the five risk + engines across, severity-weighted intensity in the cell. + + `/portfolio/executive` and `/portfolio/construction` roll up performance; neither answers *which + engine is hot on which project*. Cells come from `risk_board.board` unchanged, so a cell and the + project's own risk panel cannot disagree — which is why the sweep is bounded by `limit` (each + project is a full board, Monte-Carlo included) and reports `truncated` rather than quietly + scanning a prefix. That prefix is by project name, not by risk: ranking is what the sweep + produces, so it cannot choose what to sweep. A cell whose engine could not run reads `error`, + never 0. + """ + from .. import risk_portfolio + _allowed = rbac.member_project_ids(db, _) # membership scope (None = no restriction) + _q = db.query(Project) + if _allowed is not None: + _q = _q.filter(Project.id.in_(_allowed)) + # (name, id): `Project.name` is NOT unique, so name alone leaves tied rows in whatever + # order the engine returns — and this route promises a DETERMINISTIC prefix when it + # truncates. A tie straddling the `limit` boundary would otherwise scan a different + # project run to run. The id is the primary key, so it settles every tie. + projects = [(p.id, p.name) for p in _q.order_by(Project.name, Project.id).all()] + return risk_portfolio.heatmap(db, projects, limit=max(1, min(int(limit), 100))) + + @router.get("/portfolio/prioritization") def portfolio_prioritization(db: Session = Depends(get_db), user: str = Depends(rbac.current_user)): """Ranked portfolio prioritization — scores each accessible project 0–100 on return / on-budget / diff --git a/services/api/test_risk_portfolio.py b/services/api/test_risk_portfolio.py new file mode 100644 index 00000000..1adfeb91 --- /dev/null +++ b/services/api/test_risk_portfolio.py @@ -0,0 +1,198 @@ +"""RISK-PORTFOLIO — the portfolio risk heat map (`GET /portfolio/risk`), plus the gate that keeps +`risk_board.LANES` honest against a REAL board run. + +The gate is the point of this file as much as the heat map is. `board` reports coverage under lane +keys (`schedule_risk`) while its items carry source strings (`schedule-risk`), and only `LANES` +connects the two. A roll-up joins on both, so a lane added to `board` without a `LANES` entry would +render as a column that silently never lights up. Asserting the table against what `board` actually +emits — not against a hand-written list — is what makes that fail instead. + +Run: PYTHONPATH=src ./.venv/bin/python test_risk_portfolio.py""" +import os + +os.environ["DATABASE_URL"] = "sqlite:///./test_risk_portfolio.db" +os.environ["STORAGE_DIR"] = "./test_storage_riskportfolio" +os.environ["AEC_TRUST_XUSER"] = "1" +os.environ.pop("AEC_RBAC", None) +for _f in ("./test_risk_portfolio.db",): + if os.path.exists(_f): + os.remove(_f) + +from datetime import date, timedelta # noqa: E402 + +from fastapi.testclient import TestClient # noqa: E402 + +from aec_api import risk_portfolio # noqa: E402 +from aec_api.main import app # noqa: E402 +from aec_api.risk_board import LANES # noqa: E402 + +HDR = {"X-User": "pm"} +LANE_KEYS = {k for k, _s, _l in LANES} +LANE_SOURCES = {s for _k, s, _l in LANES} + +with TestClient(app) as c: + quiet = c.post("/projects", json={"name": "AAA Quiet"}, headers=HDR).json()["id"] + hot = c.post("/projects", json={"name": "BBB Hot"}, headers=HDR).json()["id"] + + # --- seed real signals on the hot project only --------------------------------------------- + late = (date.today() - timedelta(days=10)).isoformat() + assert c.post(f"/projects/{hot}/modules/schedule_activity", json={"data": { + "name": "Foundations", "wbs": "1.1", "duration": 10, + "start": late, "finish": late, "percent": 20}}, headers=HDR).status_code == 201 + assert c.post(f"/projects/{hot}/modules/schedule_activity", json={"data": { + "name": "Frame", "wbs": "1.2", "duration": 20, "predecessors": "1.1"}}, + headers=HDR).status_code == 201 + c.post(f"/projects/{hot}/topics", json={"type": "clash", "title": "Beam vs duct", + "priority": "high", "due_date": late}, headers=HDR) + + # --- THE GATE: LANES agrees with what `board` actually emits -------------------------------- + from aec_api import risk_board # noqa: E402 + from aec_api.db import SessionLocal # noqa: E402 + _db = SessionLocal() + try: + live = risk_board.board(_db, hot) + finally: + _db.close() + assert set(live["lanes"]) == LANE_KEYS, (set(live["lanes"]) ^ LANE_KEYS) + emitted = {i["source"] for i in live["items"]} + assert emitted <= LANE_SOURCES, emitted - LANE_SOURCES + assert emitted, "the seeded project raised no items, so this run proves nothing about sources" + # every key/source/label distinct — a duplicate would collapse two engines into one column + assert len({k for k, _s, _l in LANES}) == len(LANES) == len(LANE_SOURCES) == len( + {lbl for _k, _s, lbl in LANES}), LANES + + # --- the heat map -------------------------------------------------------------------------- + r = c.get("/portfolio/risk", headers=HDR) + assert r.status_code == 200, r.text[:300] + h = r.json() + assert h["project_count"] == 2 and h["projects_available"] == 2 and not h["truncated"], h + names = [p["name"] for p in h["projects"]] + assert set(names) == {"AAA Quiet", "BBB Hot"}, names + # sorted by intensity, not by name — the hot project leads despite sorting last alphabetically + assert names[0] == "BBB Hot", names + + hotrow = h["projects"][0] + quietrow = h["projects"][1] + assert set(hotrow["cells"]) == LANE_KEYS, hotrow["cells"].keys() + assert hotrow["score"] > 0 and hotrow["count"] >= 2, hotrow + assert hotrow["score"] == 3 * hotrow["high"] + 2 * hotrow["medium"] + 1 * hotrow["low"], hotrow + assert hotrow["band"] in ("elevated", "critical"), hotrow["band"] + assert quietrow["score"] == 0 and quietrow["band"] in ("clear", "watch"), quietrow + + # the seeded signals land in the columns that own them, not smeared across the row + assert hotrow["cells"]["coordination"]["count"] >= 1, hotrow["cells"]["coordination"] + assert hotrow["cells"]["schedule_alerts"]["count"] >= 1, hotrow["cells"]["schedule_alerts"] + + # --- A MEASURED ZERO IS NOT AN UNMEASURED CELL --------------------------------------------- + # The quiet project's cells all ran and all found nothing: state ok, counts 0. That is the + # claim a heat map makes, and it must be distinguishable from a cell that never ran. + for k, cell in quietrow["cells"].items(): + assert cell["state"] == "ok", (k, cell) + assert cell["count"] == 0 and cell["score"] == 0, (k, cell) + assert h["coverage"]["errored"] == 0 and h["coverage"]["unknown"] == 0, h["coverage"] + assert h["coverage"]["cells"] == 2 * len(LANES) == h["coverage"]["measured"], h["coverage"] + assert h["coverage"]["pct"] == 100.0, h["coverage"] + + # per-source roll-up totals reconcile with the rows + for s in h["sources"]: + k = s["key"] + assert s["count"] == sum(p["cells"][k].get("count", 0) for p in h["projects"]), s + assert s["projects_measured"] == 2 and s["projects_error"] == 0, s + assert h["totals"]["count"] == sum(p["count"] for p in h["projects"]), h["totals"] + assert h["totals"]["score"] == sum(p["score"] for p in h["projects"]), h["totals"] + assert h["band_tally"][hotrow["band"]] >= 1, h["band_tally"] + + # hotspots point at the hot project, ranked, each carrying the item that made it hot + assert h["hotspots"], h + assert h["hotspots"][0]["project"] == "BBB Hot", h["hotspots"][0] + assert all(x["title"] and x["link"] for x in h["hotspots"]), h["hotspots"] + assert [x["score"] for x in h["hotspots"]] == sorted( + (x["score"] for x in h["hotspots"]), reverse=True), h["hotspots"] + assert all(x["source"] in LANE_KEYS for x in h["hotspots"]), h["hotspots"] + + # --- truncation is reported, never silent --------------------------------------------------- + t = c.get("/portfolio/risk?limit=1", headers=HDR).json() + assert t["project_count"] == 1 and t["projects_available"] == 2 and t["truncated"], t + assert t["coverage"]["cells"] == len(LANES), t["coverage"] + # limit is clamped, not trusted: 0 and a huge value both land in range + assert c.get("/portfolio/risk?limit=0", headers=HDR).json()["limit"] == 1 + assert c.get("/portfolio/risk?limit=9999", headers=HDR).json()["limit"] == 100 + + # --- the truncated prefix is DETERMINISTIC, and name alone does not make it so --------------- + # `Project.name` is not unique. Ordering by name alone leaves tied rows in whatever order the + # engine returns, so a tie straddling the `limit` boundary scans a different project run to run + # — against this route's own stated contract. The fix orders by (name, id); the id is the + # primary key, so it settles every tie. + # + # These sort last alphabetically, so they form the tail of the scan order and the cut lands + # inside them. Ids are uuid4, so their sorted order is independent of insertion order: under the + # defect the scan would take the first 4 INSERTED, and this asserts it takes the 4 LOWEST-ID. + # Those coincide with probability 1/C(8,4) = 1/70, so a regression escapes ~1.4% of the time — + # stated rather than hidden, since nothing here can pin a uuid. + tied = [c.post("/projects", json={"name": "ZZZ Tied"}, headers=HDR).json()["id"] for _ in range(8)] + assert len(set(tied)) == 8, tied + d = c.get("/portfolio/risk?limit=6", headers=HDR).json() # AAA + BBB + 4 of the 8 tied + assert d["project_count"] == 6 and d["projects_available"] == 10, d + got = {p["id"] for p in d["projects"] if p["name"] == "ZZZ Tied"} + assert got == set(sorted(tied)[:4]), (sorted(got), sorted(tied)[:4]) + +# --- a broken lane renders as `error`, not as a clear cell -------------------------------------- +# The module's central claim, exercised directly: `board` is fail-open, so a lane whose engine +# raises reports "error" and contributes no items. The heat map must carry that through instead of +# rendering the resulting absence of items as zeros. +def _with_board(fn, projects): + """Run the heat map against a stand-in `board`. Patching the FUNCTION, not `sys.modules`: + `heatmap` does `from . import risk_board`, which resolves to the attribute already set on the + package, so swapping the module entry does nothing — a first draft of this test did exactly + that and passed while measuring the real engine.""" + import aec_api.risk_board as rb + saved = rb.board + rb.board = fn + try: + return risk_portfolio.heatmap(None, projects) + finally: + rb.board = saved + + +def _fixed(lanes, items): + """A `board` stand-in returning fixed lanes and items for every project.""" + return lambda _db, _pid: {"lanes": lanes, "items": items, "band": "watch", + "count": len(items), "by_severity": {}} + + +hm = _with_board(_fixed({"schedule_risk": "error", "schedule_alerts": "ok", "evm": "ok", + "preflight": "ok", "coordination": "ok"}, + [{"source": "coordination", "severity": "high", "title": "t", + "link": "/l"}]), [("p1", "One")]) + +cell = hm["projects"][0]["cells"]["schedule_risk"] +assert cell == {"state": "error"}, cell # no counts at all — not {"high": 0, ...} +assert "score" not in cell and "count" not in cell, cell +assert hm["coverage"]["errored"] == 1 and hm["coverage"]["measured"] == 4, hm["coverage"] +assert hm["coverage"]["pct"] == 80.0, hm["coverage"] +assert hm["sources"][0]["key"] == "schedule_risk" +assert hm["sources"][0]["projects_error"] == 1 and hm["sources"][0]["projects_measured"] == 0 +assert hm["projects"][0]["measured_sources"] == 4, hm["projects"][0] +assert hm["projects"][0]["score"] == 3, hm["projects"][0] # the one high coordination item + + +def _raises(_db, _pid): + """A `board` that fails outright, so the whole project comes back unmeasured.""" + raise RuntimeError("engine down") + + +# a board that fails outright keeps the project on the map, wholly unmeasured +hm2 = _with_board(_raises, [("p1", "One")]) +assert len(hm2["projects"]) == 1 and hm2["projects"][0]["band"] is None, hm2["projects"] +assert hm2["projects"][0]["measured_sources"] == 0, hm2["projects"][0] +assert all(cl == {"state": "unknown"} for cl in hm2["projects"][0]["cells"].values()), hm2 +assert hm2["coverage"]["unknown"] == len(LANES) and hm2["coverage"]["pct"] == 0.0, hm2["coverage"] + +# an unknown source is dropped, never invented as a column +hm3 = _with_board(_fixed(dict.fromkeys(LANE_KEYS, "ok"), + [{"source": "made-up", "severity": "high", "title": "x"}]), + [("p1", "One")]) +assert set(hm3["projects"][0]["cells"]) == LANE_KEYS, hm3["projects"][0]["cells"].keys() +assert hm3["totals"]["count"] == 0 and not hm3["hotspots"], hm3 + +print("risk portfolio heat map OK")