From 571dd7f5f56e5828631b14610c652ff322edfa8b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:56:53 +0100 Subject: [PATCH 01/12] build(julia): set the Julia 1.10 floor and add the Aqua package-shape gate (#61) Project.toml declared julia = "1.9", but AcceleratorGate.jl (hard dep) requires 1.10, so the floor could never resolve. Floor is now 1.10. Adds Test/Aqua/LinearAlgebra compat bounds and Aqua to [extras]+[targets] (rsr-criteria 5.2.3, 5.2.5). runtests.jl runs Aqua.test_all first and seeds the RNG so the rand()-based tests are reproducible. --- Project.toml | 8 ++++++-- test/runtests.jl | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index 26ccc20..dfbdbbf 100644 --- a/Project.toml +++ b/Project.toml @@ -23,7 +23,10 @@ BowtieRiskROCmExt = ["AMDGPU", "KernelAbstractions"] [compat] AcceleratorGate = "0.1" -julia = "1.9" +julia = "1.10" +Test = "1" +Aqua = "0.8" +LinearAlgebra = "1" JSON3 = "1" Distributions = "0.25" CUDA = "5" @@ -32,7 +35,8 @@ AMDGPU = "1" KernelAbstractions = "0.9" [extras] +Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Test"] +test = ["Test", "Aqua"] diff --git a/test/runtests.jl b/test/runtests.jl index 8f2ae2e..45d80a2 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,7 +1,19 @@ # SPDX-License-Identifier: MPL-2.0 # (MPL-2.0 preferred; MPL-2.0 required for Julia ecosystem) using Test +using Aqua using BowtieRisk +using Random + +# Deterministic test run: the property and simulation tests draw from rand(). +# A fixed seed makes a failure reproducible and removes run-to-run flakiness. +Random.seed!(20261010) + +# Package-shape gate runs FIRST (rsr-criteria 5.2.5): a package with stale, +# unbound or ambiguous definitions is rejected before any behaviour test runs. +@testset "BowtieRisk — Aqua package shape" begin + Aqua.test_all(BowtieRisk) +end @testset "BowtieRisk" begin hazard = Hazard(:Hazard, "Test hazard") From d15ec6764f0a10cc86c9e41e1fe7781d52e8412d Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:56:55 +0100 Subject: [PATCH 02/12] ci(julia): align Julia CI with the julia-library archetype (#61) Replaces ci.yml with julia-ci.yml: fmt (JuliaFormatter, check mode), JET and test jobs, matrix ["1.10", "1"] so the floor is tested, ubuntu-24.04, fail-fast: false, no continue-on-error. Pins use commit SHAs. The AcceleratorGate revision is developed before resolution in every job. --- .github/workflows/ci.yml | 23 ------- .github/workflows/julia-ci.yml | 114 +++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 23 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/julia-ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 9081f26..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,23 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -name: CI -on: - push: - pull_request: -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - matrix: - julia-version: ['1.10', '1.11'] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 - with: - version: ${{ matrix.julia-version }} - - name: Install dependencies - run: julia --project=. -e 'using Pkg; Pkg.develop(PackageSpec(url="https://github.com/hyperpolymath/AcceleratorGate.jl.git", rev="680205c9c167d1d9ab0bb5a6034852f3d8e06149")); Pkg.instantiate()' - - name: Run tests - run: julia --project=. -e 'using Pkg; Pkg.test()' -permissions: - contents: read diff --git a/.github/workflows/julia-ci.yml b/.github/workflows/julia-ci.yml new file mode 100644 index 0000000..d3ca493 --- /dev/null +++ b/.github/workflows/julia-ci.yml @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: MPL-2.0 +# BowtieRisk.jl — Julia CI (julia-library archetype, rsr-criteria 5.2.4 / 5.2.5) +# +# Shape follows rsr-template-repo archetypes/julia-library/overlay/.github/workflows/julia-ci.yml.in. +# Deviations, each deliberate: +# - AcceleratorGate.jl is not in the General registry. Every job develops it +# from its pinned git revision before resolving the project. +# - The fmt job creates a throwaway environment for JuliaFormatter instead of +# `Pkg.add` into the project, so CI never writes to Project.toml. +# +# Pin posture: symbolic refs are pinned by commit SHA (comment gives the tag). +# No `continue-on-error` anywhere: a red job is the gate working. +name: Julia CI + +on: + push: + branches: [main, master] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + ACCELERATORGATE_URL: https://github.com/hyperpolymath/AcceleratorGate.jl.git + ACCELERATORGATE_REV: 680205c9c167d1d9ab0bb5a6034852f3d8e06149 + +jobs: + fmt: + name: Format (JuliaFormatter) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 + with: + version: "1" + - uses: julia-actions/cache@9353db09a0f2bbbdec6f41791918c75d37391b36 # v3.5.0 + - name: Check formatting (check mode, never writes) + run: | + julia -e ' + using Pkg + Pkg.activate(mktempdir()) + Pkg.add("JuliaFormatter") + using JuliaFormatter + files = String[] + for dir in ("src", "test") + for (root, _, names) in walkdir(dir) + for n in names + endswith(n, ".jl") && push!(files, joinpath(root, n)) + end + end + end + bad = String[] + for f in sort(files) + text = read(f, String) + if text != JuliaFormatter.format_string(text) + push!(bad, f) + end + end + if !isempty(bad) + println(stderr, "Not formatted (run JuliaFormatter.format_file on these):") + foreach(f -> println(stderr, " ", f), bad) + exit(1) + end' + + jet: + name: Static analysis (JET) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 + with: + version: "1" + - uses: julia-actions/cache@9353db09a0f2bbbdec6f41791918c75d37391b36 # v3.5.0 + - name: JET analysis (supported API) + # JET 0.12 removed the path/string entry points; pass the loaded module. + run: | + julia -e ' + using Pkg + Pkg.activate(mktempdir()) + Pkg.develop(url=ENV["ACCELERATORGATE_URL"], rev=ENV["ACCELERATORGATE_REV"]) + Pkg.develop(path=".") + Pkg.add(Pkg.PackageSpec(name="JET", version="0.12")) + using BowtieRisk, JET + if !hasmethod(JET.test_package, (Module,)) + error("JET ", pkgversion(JET), " has no test_package(::Module)") + end + JET.test_package(BowtieRisk)' + + test: + name: Test (Test + Aqua) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + # The [compat] floor IS tested (rsr-criteria 5.2.4): an untested + # floor is an unproven floor. "1" is the current release. + julia: ["1.10", "1"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 + with: + version: ${{ matrix.julia }} + - uses: julia-actions/cache@9353db09a0f2bbbdec6f41791918c75d37391b36 # v3.5.0 + - name: Install dependencies + run: julia --project=. -e 'using Pkg; Pkg.develop(url=ENV["ACCELERATORGATE_URL"], rev=ENV["ACCELERATORGATE_REV"]); Pkg.instantiate()' + - name: Pkg.test() + run: julia --project=. -e 'using Pkg; Pkg.test()' From 8b207ffe5bb960ab4b5e074e0479726736da93e5 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:56:57 +0100 Subject: [PATCH 03/12] ci: make the BoJ trigger self-gating and remove continue-on-error (#61) boj-build.yml posted to boj-server.local, which hosted runners cannot resolve, behind continue-on-error. It now uses the canonical estate form: it runs only when BOJ_SERVER_URL (secret or variable) is set. Release pins are aligned with the template lock and runners moved to ubuntu-24.04. Also fixes a concatenated .gitignore line. --- .github/workflows/boj-build.yml | 52 +++++++++++++++++++++++++++++---- .github/workflows/release.yml | 16 +++++----- .gitignore | 3 +- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/.github/workflows/boj-build.yml b/.github/workflows/boj-build.yml index c4f641d..145afd2 100644 --- a/.github/workflows/boj-build.yml +++ b/.github/workflows/boj-build.yml @@ -1,20 +1,60 @@ # SPDX-License-Identifier: MPL-2.0 +# +# OPTIONAL: BoJ Server Build Trigger +# This workflow notifies a BoJ Server instance when code is pushed. +# It is a no-op if BOJ_SERVER_URL is not set or the server is unreachable. +# To enable: set BOJ_SERVER_URL as a repository secret or variable. +# To disable: delete this file or leave BOJ_SERVER_URL unset. name: BoJ Server Build Trigger + on: push: branches: [main, master] workflow_dispatch: + +# Estate guardrail: cancel superseded runs so re-pushes / rebased PR +# updates do not pile up queued runs against the shared account-wide +# Actions concurrency pool. Applied only to read-only check workflows +# (no publish/mutation), so cancelling a superseded run is always safe. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + actions: read + contents: read + jobs: trigger-boj: + name: Trigger BoJ server runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 10 + # No job-level gate: the `secrets` context is not available in `if:`. + # The Trigger step self-gates: it exits 0 when BOJ_URL is empty. steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Trigger BoJ Server (Casket/ssg-mcp) + env: + BOJ_URL: ${{ secrets.BOJ_SERVER_URL || vars.BOJ_SERVER_URL }} + REPO_NAME: ${{ github.repository }} + BRANCH_NAME: ${{ github.ref_name }} run: | - # Send a secure trigger to boj-server to build this repository - curl -X POST "http://boj-server.local:7700/cartridges/ssg-mcp/invoke" -H "Content-Type: application/json" -d "{\"repo\": \"${{ github.repository }}\", \"branch\": \"${{ github.ref_name }}\", \"engine\": \"casket\\"}"} - continue-on-error: true -permissions: - contents: read + set -euo pipefail + + if [ -z "$BOJ_URL" ]; then + echo "BOJ_SERVER_URL not configured - skipping" + exit 0 + fi + + payload="$(jq -cn \ + --arg repo "$REPO_NAME" \ + --arg branch "$BRANCH_NAME" \ + --arg engine "casket" \ + '{repo:$repo, branch:$branch, engine:$engine}')" + + curl -sf -X POST "${BOJ_URL}/cartridges/ssg-mcp/invoke" \ + -H "Content-Type: application/json" \ + --data "$payload" \ + || echo "BoJ server unreachable - skipping (non-fatal)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index def5d8c..e93796f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,17 +19,17 @@ jobs: timeout-minutes: 15 strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-24.04, macos-latest, windows-latest] julia-version: ['1.10'] steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 with: version: ${{ matrix.julia-version }} - - uses: julia-actions/cache@e32267b41e0fa7df82a3d53e8c7273620d52c586 # v3.4.0 + - uses: julia-actions/cache@9353db09a0f2bbbdec6f41791918c75d37391b36 # v3.5.0 - name: Build and test run: | - julia --project -e 'using Pkg; Pkg.build(); Pkg.test()' + julia --project -e 'using Pkg; Pkg.develop(url="https://github.com/hyperpolymath/AcceleratorGate.jl.git", rev="680205c9c167d1d9ab0bb5a6034852f3d8e06149"); Pkg.instantiate(); Pkg.build(); Pkg.test()' - name: Package artifacts run: | mkdir -p dist @@ -41,11 +41,11 @@ jobs: path: dist/ sbom: name: Generate SBOM - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 needs: build steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 with: version: '1.10' @@ -85,11 +85,11 @@ jobs: path: sbom.json release: name: Create Release - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 needs: [build, sbom] steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.gitignore b/.gitignore index b343655..70a9ba2 100644 --- a/.gitignore +++ b/.gitignore @@ -115,7 +115,8 @@ htmlcov/ # Temp /tmp/ *.tmp -*.bakSONNET-TASKS.md +*.bak +SONNET-TASKS.md target/ node_modules/ _build/ From d60728070e298d1338f0f26f5ce686e95015160a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:56:58 +0100 Subject: [PATCH 04/12] docs: declare docs-site with a Documenter build and add rsr-profile (#61) docs/make.jl already expected index.md and api.md, but docs/src held only AsciiDoc, so the build could not run. Converts both pages to Markdown and adds a docs environment and julia-docs.yml. Declares the julia-library preset in .machine_readable/rsr-profile.a2ml (julia, library, docs-site, published-package), so these capabilities are enforced. --- .github/workflows/julia-docs.yml | 55 +++++++++++++++++++++++ .machine_readable/rsr-profile.a2ml | 16 +++++++ docs/Project.toml | 10 +++++ docs/src/{api.adoc => api.md} | 72 +++++++++++++----------------- docs/src/{index.adoc => index.md} | 28 ++++++------ 5 files changed, 127 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/julia-docs.yml create mode 100644 .machine_readable/rsr-profile.a2ml create mode 100644 docs/Project.toml rename docs/src/{api.adoc => api.md} (50%) rename docs/src/{index.adoc => index.md} (52%) diff --git a/.github/workflows/julia-docs.yml b/.github/workflows/julia-docs.yml new file mode 100644 index 0000000..0ea5c3e --- /dev/null +++ b/.github/workflows/julia-docs.yml @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: MPL-2.0 +# BowtieRisk.jl — docs build & deploy (julia-library archetype, criterion 5.2.6) +# +# Builds the Documenter site from docs/ and deploys it to gh-pages with +# GITHUB_TOKEN (deploydocs is called from docs/make.jl). +# +# Deviation from the template: AcceleratorGate.jl is developed from its pinned +# git revision before the package is developed, because it is not in the +# General registry and resolution would otherwise fail. +name: Docs + +on: + push: + branches: [main, master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + docs: + name: Build and deploy + runs-on: ubuntu-24.04 + timeout-minutes: 20 + # deploydocs pushes the built site to the gh-pages branch. + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Layout gate — fail loud while docs/ is absent + run: | + if [ ! -f docs/Project.toml ] || [ ! -f docs/make.jl ] || [ ! -d docs/src ]; then + echo "::error::docs-site declared but no Documenter build: docs/Project.toml, docs/make.jl and docs/src/ are required." + exit 1 + fi + - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 + with: + version: "1" + - uses: julia-actions/cache@9353db09a0f2bbbdec6f41791918c75d37391b36 # v3.5.0 + - name: Instantiate the docs environment + run: | + julia --project=docs -e 'using Pkg + Pkg.develop(url="https://github.com/hyperpolymath/AcceleratorGate.jl.git", rev="680205c9c167d1d9ab0bb5a6034852f3d8e06149") + Pkg.develop(PackageSpec(path=pwd())) + Pkg.instantiate()' + - name: Build and deploy (Documenter deploydocs) + run: julia --project=docs docs/make.jl + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.machine_readable/rsr-profile.a2ml b/.machine_readable/rsr-profile.a2ml new file mode 100644 index 0000000..01ec264 --- /dev/null +++ b/.machine_readable/rsr-profile.a2ml @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# rsr-profile.a2ml — capability declaration for BowtieRisk.jl. +# +# Declares the julia-library preset (template-capability-gates.toml [presets]): +# julia, library, docs-site, published-package +# Each declared capability is enforced: docs-site requires docs/ (Documenter +# build, see .github/workflows/julia-docs.yml); published-package requires +# the release workflow. Scored against rsr-criteria-v2 (declares-against 2.1.0). + +[rsr-profile] +version = "1.0.0" +spec = "rsr-criteria-v2" +declares-against = "2.1.0" +preset = "julia-library" diff --git a/docs/Project.toml b/docs/Project.toml new file mode 100644 index 0000000..3bc8585 --- /dev/null +++ b/docs/Project.toml @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: MPL-2.0 +# Documentation environment for BowtieRisk.jl (docs-site capability). +# BowtieRisk itself is developed into this environment by the docs workflow +# and by the local build instructions; it is not a dependency here. +[deps] +Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" + +[compat] +Documenter = "1" +julia = "1.10" diff --git a/docs/src/api.adoc b/docs/src/api.md similarity index 50% rename from docs/src/api.adoc rename to docs/src/api.md index c682c67..70f1289 100644 --- a/docs/src/api.adoc +++ b/docs/src/api.md @@ -1,92 +1,82 @@ -== API Reference +# API Reference -=== Core Data Structures +## Core Data Structures -[source,@docs] ----- +```@docs Hazard Threat TopEvent Consequence Barrier EscalationFactor ----- +``` -=== Model Components +## Model Components -[source,@docs] ----- +```@docs ProbabilityModel ThreatPath ConsequencePath BowtieModel ----- +``` -=== Simulation +## Simulation -[source,@docs] ----- +```@docs BarrierDistribution SimulationResult simulate ----- +``` -=== Evaluation +## Evaluation -[source,@docs] ----- +```@docs BowtieSummary evaluate sensitivity_tornado ----- +``` -=== Event Chains +## Event Chains -[source,@docs] ----- +```@docs Event EventChain chain_probability ----- +``` -=== Visualization +## Visualization -[source,@docs] ----- +```@docs to_mermaid to_graphviz ----- +``` -=== Reports +## Reports -[source,@docs] ----- +```@docs report_markdown write_report_markdown write_tornado_csv ----- +``` -=== Serialization +## Serialization -[source,@docs] ----- +```@docs write_model_json read_model_json write_schema_json model_schema ----- +``` -=== Templates +## Templates -[source,@docs] ----- +```@docs list_templates template_model ----- +``` -=== Data Import +## Data Import -[source,@docs] ----- +```@docs load_simple_csv ----- +``` diff --git a/docs/src/index.adoc b/docs/src/index.md similarity index 52% rename from docs/src/index.adoc rename to docs/src/index.md index 68bc275..040d416 100644 --- a/docs/src/index.adoc +++ b/docs/src/index.md @@ -1,19 +1,21 @@ -== BowtieRisk.jl +# BowtieRisk.jl -Documentation for BowtieRisk.jl +Documentation for BowtieRisk.jl: bow-tie risk analysis with barrier assessment and Monte Carlo simulation. -=== Installation +## Installation -[source,julia] ----- +BowtieRisk depends on AcceleratorGate.jl, which is not in the General registry. Develop it first, from the pinned revision: + +```julia using Pkg +Pkg.develop(url="https://github.com/hyperpolymath/AcceleratorGate.jl.git", + rev="680205c9c167d1d9ab0bb5a6034852f3d8e06149") Pkg.add(url="https://github.com/hyperpolymath/BowtieRisk.jl") ----- +``` -=== Quick Start +## Quick Start -[source,julia] ----- +```julia using BowtieRisk # Use a template model @@ -34,10 +36,10 @@ println("Mean: ", sim.top_event_mean) # Export to Mermaid diagram diagram = to_mermaid(model) println(diagram) ----- +``` -See `+examples/basic_bowtie.jl+` for a comprehensive example. +See `examples/basic_bowtie.jl` for a comprehensive example. -=== API Reference +## API Reference -See link:api.md[API] for complete reference. +See [API](api.md) for the complete reference. From b6e3b963ef14ad39b9f269efe0357b8b4cb417e8 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:07:07 +0100 Subject: [PATCH 05/12] Update .github/workflows/julia-ci.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- .github/workflows/julia-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/julia-ci.yml b/.github/workflows/julia-ci.yml index d3ca493..28a036d 100644 --- a/.github/workflows/julia-ci.yml +++ b/.github/workflows/julia-ci.yml @@ -35,6 +35,8 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 with: version: "1" From d98c41ab3c4d97597c885a4d4a4f96a3f3cf66fa Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:08:14 +0100 Subject: [PATCH 06/12] ci(julia): publish install and JET diagnostics as annotations (#61, temporary) Temporary instrumentation: the failing install/format/JET output is not visible in logs outside the UI, so it is emitted as check annotations. Removed once the root cause is fixed. --- .github/scripts/emit-job-diagnostics.py | 11 ++++++++ .github/workflows/julia-ci.yml | 37 ++++++++++++++++++++----- 2 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/emit-job-diagnostics.py diff --git a/.github/scripts/emit-job-diagnostics.py b/.github/scripts/emit-job-diagnostics.py new file mode 100644 index 0000000..05f108e --- /dev/null +++ b/.github/scripts/emit-job-diagnostics.py @@ -0,0 +1,11 @@ +# TEMPORARY diagnostic (#61): publish the tail of a failing step's output as +# check annotations, because raw job logs are not retrievable outside the UI. +# Removed once the root cause of the Julia install/format/JET failures is fixed. +import pathlib, sys +path = pathlib.Path(sys.argv[1]) +text = path.read_text(errors="replace") if path.exists() else "(no log captured)" +text = text[-12000:] +def esc(s): + return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") +for i in range(0, len(text), 3000): + print(f"::error title=diag {i // 3000}::{esc(text[i:i + 3000])}") diff --git a/.github/workflows/julia-ci.yml b/.github/workflows/julia-ci.yml index 28a036d..7cbb18b 100644 --- a/.github/workflows/julia-ci.yml +++ b/.github/workflows/julia-ci.yml @@ -10,6 +10,9 @@ # # Pin posture: symbolic refs are pinned by commit SHA (comment gives the tag). # No `continue-on-error` anywhere: a red job is the gate working. +# +# TEMPORARY: the `diag` steps publish failing output as annotations (see +# .github/scripts/emit-job-diagnostics.py). Remove with that script. name: Julia CI on: @@ -35,14 +38,13 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 with: version: "1" - uses: julia-actions/cache@9353db09a0f2bbbdec6f41791918c75d37391b36 # v3.5.0 - name: Check formatting (check mode, never writes) run: | + set +e julia -e ' using Pkg Pkg.activate(mktempdir()) @@ -64,10 +66,16 @@ jobs: end end if !isempty(bad) - println(stderr, "Not formatted (run JuliaFormatter.format_file on these):") - foreach(f -> println(stderr, " ", f), bad) + println("Not formatted (run JuliaFormatter.format_file on these):") + foreach(f -> println(" ", f), bad) exit(1) - end' + end' > fmt.log 2>&1 + rc=$? + cat fmt.log + exit $rc + - name: diag + if: failure() + run: python3 .github/scripts/emit-job-diagnostics.py fmt.log jet: name: Static analysis (JET) @@ -82,6 +90,7 @@ jobs: - name: JET analysis (supported API) # JET 0.12 removed the path/string entry points; pass the loaded module. run: | + set +e julia -e ' using Pkg Pkg.activate(mktempdir()) @@ -92,7 +101,13 @@ jobs: if !hasmethod(JET.test_package, (Module,)) error("JET ", pkgversion(JET), " has no test_package(::Module)") end - JET.test_package(BowtieRisk)' + JET.test_package(BowtieRisk)' > jet.log 2>&1 + rc=$? + cat jet.log + exit $rc + - name: diag + if: failure() + run: python3 .github/scripts/emit-job-diagnostics.py jet.log test: name: Test (Test + Aqua) @@ -111,6 +126,14 @@ jobs: version: ${{ matrix.julia }} - uses: julia-actions/cache@9353db09a0f2bbbdec6f41791918c75d37391b36 # v3.5.0 - name: Install dependencies - run: julia --project=. -e 'using Pkg; Pkg.develop(url=ENV["ACCELERATORGATE_URL"], rev=ENV["ACCELERATORGATE_REV"]); Pkg.instantiate()' + run: | + set +e + julia --project=. -e 'using Pkg; Pkg.develop(url=ENV["ACCELERATORGATE_URL"], rev=ENV["ACCELERATORGATE_REV"]); Pkg.instantiate()' > install.log 2>&1 + rc=$? + cat install.log + exit $rc + - name: diag + if: failure() + run: python3 .github/scripts/emit-job-diagnostics.py install.log - name: Pkg.test() run: julia --project=. -e 'using Pkg; Pkg.test()' From a90081c05aba5dffd8ace3968fa922ba33b9fdb9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:10:17 +0100 Subject: [PATCH 07/12] fix(ci): install AcceleratorGate with Pkg.add, not develop; use JuliaFormatter.format_text (#61) Root cause of the red test (1.10) and test (1.11) checks: Pkg.develop(url=..., rev=...) is rejected by Pkg ('rev argument not supported by develop'), so every job failed in its install step before any test ran. This is also why the failure predates #60. Pkg.add(url=..., rev=...) pins the same revision.\n\nThe same call is fixed in the JET job, the docs workflow, the release workflow and the user install docs. The release notes now install AcceleratorGate first, since it is not in the General registry. The fmt job uses JuliaFormatter.format_text, because format_string does not exist in JuliaFormatter 2.x. --- .github/workflows/julia-ci.yml | 6 +++--- .github/workflows/julia-docs.yml | 2 +- .github/workflows/release.yml | 3 ++- docs/src/index.md | 6 +++--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/julia-ci.yml b/.github/workflows/julia-ci.yml index 7cbb18b..85046bb 100644 --- a/.github/workflows/julia-ci.yml +++ b/.github/workflows/julia-ci.yml @@ -61,7 +61,7 @@ jobs: bad = String[] for f in sort(files) text = read(f, String) - if text != JuliaFormatter.format_string(text) + if text != JuliaFormatter.format_text(text) push!(bad, f) end end @@ -94,7 +94,7 @@ jobs: julia -e ' using Pkg Pkg.activate(mktempdir()) - Pkg.develop(url=ENV["ACCELERATORGATE_URL"], rev=ENV["ACCELERATORGATE_REV"]) + Pkg.add(url=ENV["ACCELERATORGATE_URL"], rev=ENV["ACCELERATORGATE_REV"]) Pkg.develop(path=".") Pkg.add(Pkg.PackageSpec(name="JET", version="0.12")) using BowtieRisk, JET @@ -128,7 +128,7 @@ jobs: - name: Install dependencies run: | set +e - julia --project=. -e 'using Pkg; Pkg.develop(url=ENV["ACCELERATORGATE_URL"], rev=ENV["ACCELERATORGATE_REV"]); Pkg.instantiate()' > install.log 2>&1 + julia --project=. -e 'using Pkg; Pkg.add(url=ENV["ACCELERATORGATE_URL"], rev=ENV["ACCELERATORGATE_REV"]); Pkg.instantiate()' > install.log 2>&1 rc=$? cat install.log exit $rc diff --git a/.github/workflows/julia-docs.yml b/.github/workflows/julia-docs.yml index 0ea5c3e..90fea3a 100644 --- a/.github/workflows/julia-docs.yml +++ b/.github/workflows/julia-docs.yml @@ -46,7 +46,7 @@ jobs: - name: Instantiate the docs environment run: | julia --project=docs -e 'using Pkg - Pkg.develop(url="https://github.com/hyperpolymath/AcceleratorGate.jl.git", rev="680205c9c167d1d9ab0bb5a6034852f3d8e06149") + Pkg.add(url="https://github.com/hyperpolymath/AcceleratorGate.jl.git", rev="680205c9c167d1d9ab0bb5a6034852f3d8e06149") Pkg.develop(PackageSpec(path=pwd())) Pkg.instantiate()' - name: Build and deploy (Documenter deploydocs) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e93796f..d1056b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - uses: julia-actions/cache@9353db09a0f2bbbdec6f41791918c75d37391b36 # v3.5.0 - name: Build and test run: | - julia --project -e 'using Pkg; Pkg.develop(url="https://github.com/hyperpolymath/AcceleratorGate.jl.git", rev="680205c9c167d1d9ab0bb5a6034852f3d8e06149"); Pkg.instantiate(); Pkg.build(); Pkg.test()' + julia --project -e 'using Pkg; Pkg.add(url="https://github.com/hyperpolymath/AcceleratorGate.jl.git", rev="680205c9c167d1d9ab0bb5a6034852f3d8e06149"); Pkg.instantiate(); Pkg.build(); Pkg.test()' - name: Package artifacts run: | mkdir -p dist @@ -118,6 +118,7 @@ jobs: ```julia using Pkg + Pkg.add(url="https://github.com/hyperpolymath/AcceleratorGate.jl", rev="680205c9c167d1d9ab0bb5a6034852f3d8e06149") Pkg.add(url="https://github.com/hyperpolymath/BowtieRisk.jl", rev="${{ steps.version.outputs.version }}") ``` diff --git a/docs/src/index.md b/docs/src/index.md index 040d416..4dce7f2 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -4,12 +4,12 @@ Documentation for BowtieRisk.jl: bow-tie risk analysis with barrier assessment a ## Installation -BowtieRisk depends on AcceleratorGate.jl, which is not in the General registry. Develop it first, from the pinned revision: +BowtieRisk depends on AcceleratorGate.jl, which is not in the General registry. Add it first, from the pinned revision: ```julia using Pkg -Pkg.develop(url="https://github.com/hyperpolymath/AcceleratorGate.jl.git", - rev="680205c9c167d1d9ab0bb5a6034852f3d8e06149") +Pkg.add(url="https://github.com/hyperpolymath/AcceleratorGate.jl.git", + rev="680205c9c167d1d9ab0bb5a6034852f3d8e06149") Pkg.add(url="https://github.com/hyperpolymath/BowtieRisk.jl") ``` From 3a255dce44f9b6b42ac2504ad97f2f343fa53045 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:13:37 +0100 Subject: [PATCH 08/12] fix(model): typed JSON accessors in read_model_json; instrument Pkg.test (#61) JET reports every read_model_json field access as a possible MethodError, because JSON3 values are Unions that include Nothing. Typed accessors check each value once and raise ArgumentError on malformed input. Also publishes Pkg.test failure output as annotations (temporary). Adds a temporary JuliaFormatter apply workflow, which commits through the API so GitHub signs it. --- .github/scripts/tmp-commit-formatted.py | 41 ++++++++++ .github/scripts/tmp-format-apply.jl | 18 +++++ .github/workflows/julia-ci.yml | 10 ++- .github/workflows/tmp-format-apply.yml | 34 ++++++++ src/BowtieRisk.jl | 103 +++++++++++++++--------- 5 files changed, 165 insertions(+), 41 deletions(-) create mode 100644 .github/scripts/tmp-commit-formatted.py create mode 100644 .github/scripts/tmp-format-apply.jl create mode 100644 .github/workflows/tmp-format-apply.yml diff --git a/.github/scripts/tmp-commit-formatted.py b/.github/scripts/tmp-commit-formatted.py new file mode 100644 index 0000000..2bd4248 --- /dev/null +++ b/.github/scripts/tmp-commit-formatted.py @@ -0,0 +1,41 @@ +# TEMPORARY (#61): commit whatever the formatter changed through the GraphQL +# createCommitOnBranch mutation, so GitHub signs the commit. +import base64 +import json +import os +import subprocess +import sys + +repo = os.environ["GITHUB_REPOSITORY"] +branch = os.environ["GITHUB_REF_NAME"] +head = os.environ["GITHUB_SHA"] + +files = subprocess.check_output(["git", "diff", "--name-only"]).decode().split() +if not files: + print("nothing to format") + sys.exit(0) + +additions = [ + {"path": f, "contents": base64.b64encode(open(f, "rb").read()).decode()} + for f in files +] +query = ( + "mutation($input: CreateCommitOnBranchInput!) {" + " createCommitOnBranch(input: $input) { commit { oid } } }" +) +variables = { + "input": { + "branch": {"repositoryNameWithOwner": repo, "branchName": branch}, + "message": { + "headline": "style(format): apply JuliaFormatter to src and test (#61)", + "body": "Automated JuliaFormatter pass (DefaultStyle) over src/ and test/, " + "committed through the GitHub API so GitHub signs it.", + }, + "fileChanges": {"additions": additions, "deletions": []}, + "expectedHeadOid": head, + } +} +with open("/tmp/format-req.json", "w") as fh: + json.dump({"query": query, "variables": variables}, fh) +subprocess.run(["gh", "api", "graphql", "--input", "/tmp/format-req.json"], check=True) +print("committed formatted files:", files) diff --git a/.github/scripts/tmp-format-apply.jl b/.github/scripts/tmp-format-apply.jl new file mode 100644 index 0000000..81f52f6 --- /dev/null +++ b/.github/scripts/tmp-format-apply.jl @@ -0,0 +1,18 @@ +# TEMPORARY (#61): formats src/ and test/ in place with JuliaFormatter. +using Pkg +Pkg.activate(mktempdir()) +Pkg.add("JuliaFormatter") +using JuliaFormatter + +files = String[] +for dir in ("src", "test") + for (root, _, names) in walkdir(dir) + for n in names + endswith(n, ".jl") && push!(files, joinpath(root, n)) + end + end +end +for f in sort(files) + changed = format_file(f) + println(changed ? "formatted " : "ok ", f) +end diff --git a/.github/workflows/julia-ci.yml b/.github/workflows/julia-ci.yml index 85046bb..21c5f20 100644 --- a/.github/workflows/julia-ci.yml +++ b/.github/workflows/julia-ci.yml @@ -136,4 +136,12 @@ jobs: if: failure() run: python3 .github/scripts/emit-job-diagnostics.py install.log - name: Pkg.test() - run: julia --project=. -e 'using Pkg; Pkg.test()' + run: | + set +e + julia --project=. -e 'using Pkg; Pkg.test()' > test.log 2>&1 + rc=$? + cat test.log + exit $rc + - name: diag + if: failure() + run: python3 .github/scripts/emit-job-diagnostics.py test.log diff --git a/.github/workflows/tmp-format-apply.yml b/.github/workflows/tmp-format-apply.yml new file mode 100644 index 0000000..d26d362 --- /dev/null +++ b/.github/workflows/tmp-format-apply.yml @@ -0,0 +1,34 @@ +# TEMPORARY (#61): apply JuliaFormatter to src/ and test/ on this branch and +# commit the result through the GitHub API, so GitHub signs it. Removed once +# the formatted sources are on the branch. No-op when nothing changes. +name: TEMP format-apply + +on: + push: + branches: [arena/ebd419f3-bowtierisk-jl] + +permissions: + contents: write + +concurrency: + group: tmp-format-apply-${{ github.ref }} + cancel-in-progress: false + +jobs: + apply: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 + with: + version: "1" + - name: Apply JuliaFormatter (DefaultStyle) + run: julia .github/scripts/tmp-format-apply.jl + - name: Commit formatted files through the API (GitHub-signed) + env: + GH_TOKEN: ${{ github.token }} + GITHUB_SHA: ${{ github.sha }} + run: python3 .github/scripts/tmp-commit-formatted.py diff --git a/src/BowtieRisk.jl b/src/BowtieRisk.jl index f3c042b..33f2843 100644 --- a/src/BowtieRisk.jl +++ b/src/BowtieRisk.jl @@ -750,55 +750,78 @@ function write_model_json(path::AbstractString, model::BowtieModel) nothing end +# Typed accessors for JSON3 values. JSON3 yields a Union of JSON types, so each +# value is checked once here; a malformed model file raises ArgumentError rather +# than a MethodError deep inside the constructors. +_json_obj(x)::JSON3.Object = x isa JSON3.Object ? x : throw(ArgumentError("model JSON: expected an object, got $(typeof(x))")) +_json_arr(x)::AbstractVector = x isa AbstractVector ? x : throw(ArgumentError("model JSON: expected an array, got $(typeof(x))")) +_json_str(x)::String = x isa AbstractString ? String(x) : throw(ArgumentError("model JSON: expected a string, got $(typeof(x))")) +_json_num(x)::Float64 = x isa Real ? Float64(x) : throw(ArgumentError("model JSON: expected a number, got $(typeof(x))")) +_json_sym(x)::Symbol = Symbol(_json_str(x)) +_json_field(o, key::String) = _json_obj(o)[Symbol(key)] + +function _read_barrier(b) + Barrier( + _json_sym(_json_field(b, "name")), + _json_num(_json_field(b, "effectiveness")), + _json_sym(_json_field(b, "kind")), + _json_str(_json_field(b, "description")), + _json_num(_json_field(b, "degradation")), + _json_sym(_json_field(b, "dependency")), + ) +end + +function _read_factors(list) + [EscalationFactor( + _json_sym(_json_field(f, "name")), + _json_num(_json_field(f, "multiplier")), + _json_str(_json_field(f, "description")), + ) for f in _json_arr(list)] +end + +function _read_barriers(list) + Barrier[_read_barrier(b) for b in _json_arr(list)] +end + """ Read a bowtie model from JSON produced by write_model_json. """ function read_model_json(path::AbstractString) - obj = JSON3.read(read(path, String)) - hazard = Hazard(Symbol(String(obj["hazard"]["name"])), String(obj["hazard"]["description"])) - top_event = TopEvent(Symbol(String(obj["top_event"]["name"])), String(obj["top_event"]["description"])) - model = ProbabilityModel(Symbol(String(obj["probability_model"]["mode"]))) + obj = _json_obj(JSON3.read(read(path, String))) + hz = _json_obj(_json_field(obj, "hazard")) + te = _json_obj(_json_field(obj, "top_event")) + hazard = Hazard(_json_sym(_json_field(hz, "name")), _json_str(_json_field(hz, "description"))) + top_event = TopEvent(_json_sym(_json_field(te, "name")), _json_str(_json_field(te, "description"))) + model = ProbabilityModel(_json_sym(_json_field(_json_obj(_json_field(obj, "probability_model")), "mode"))) threat_paths = ThreatPath[] - for p in obj["threat_paths"] - threat = Threat(Symbol(String(p["threat"]["name"])), Float64(p["threat"]["probability"]), String(p["threat"]["description"])) - barriers = Barrier[] - for b in p["barriers"] - push!(barriers, Barrier( - Symbol(String(b["name"])), - Float64(b["effectiveness"]), - Symbol(String(b["kind"])), - String(b["description"]), - Float64(b["degradation"]), - Symbol(String(b["dependency"])), - )) - end - factors = EscalationFactor[] - for f in p["escalation_factors"] - push!(factors, EscalationFactor(Symbol(String(f["name"])), Float64(f["multiplier"]), String(f["description"]))) - end - push!(threat_paths, ThreatPath(threat, barriers, factors)) + for p in _json_arr(_json_field(obj, "threat_paths")) + t = _json_obj(_json_field(p, "threat")) + threat = Threat( + _json_sym(_json_field(t, "name")), + _json_num(_json_field(t, "probability")), + _json_str(_json_field(t, "description")), + ) + push!(threat_paths, ThreatPath( + threat, + _read_barriers(_json_field(p, "barriers")), + _read_factors(_json_field(p, "escalation_factors")), + )) end consequence_paths = ConsequencePath[] - for p in obj["consequence_paths"] - consequence = Consequence(Symbol(String(p["consequence"]["name"])), Float64(p["consequence"]["severity"]), String(p["consequence"]["description"])) - barriers = Barrier[] - for b in p["barriers"] - push!(barriers, Barrier( - Symbol(String(b["name"])), - Float64(b["effectiveness"]), - Symbol(String(b["kind"])), - String(b["description"]), - Float64(b["degradation"]), - Symbol(String(b["dependency"])), - )) - end - factors = EscalationFactor[] - for f in p["escalation_factors"] - push!(factors, EscalationFactor(Symbol(String(f["name"])), Float64(f["multiplier"]), String(f["description"]))) - end - push!(consequence_paths, ConsequencePath(consequence, barriers, factors)) + for p in _json_arr(_json_field(obj, "consequence_paths")) + c = _json_obj(_json_field(p, "consequence")) + consequence = Consequence( + _json_sym(_json_field(c, "name")), + _json_num(_json_field(c, "severity")), + _json_str(_json_field(c, "description")), + ) + push!(consequence_paths, ConsequencePath( + consequence, + _read_barriers(_json_field(p, "barriers")), + _read_factors(_json_field(p, "escalation_factors")), + )) end BowtieModel(hazard, top_event, threat_paths, consequence_paths, model) From 3ad041c33d419a5de30aafeb7e56d88f7f461f6c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:14:35 +0000 Subject: [PATCH 09/12] style(format): apply JuliaFormatter to src and test (#61) Automated JuliaFormatter pass (DefaultStyle) over src/ and test/, committed through the GitHub API so GitHub signs it. --- src/BowtieRisk.jl | 475 +++++++++++++++++++++++++++++---------- src/backends/abstract.jl | 44 ++-- test/e2e_test.jl | 71 ++++-- test/property_test.jl | 101 ++++++--- test/runtests.jl | 275 ++++++++++++++++------- 5 files changed, 705 insertions(+), 261 deletions(-) diff --git a/src/BowtieRisk.jl b/src/BowtieRisk.jl index 33f2843..1ed89f9 100644 --- a/src/BowtieRisk.jl +++ b/src/BowtieRisk.jl @@ -158,7 +158,7 @@ Kinds: :fixed, :beta, :triangular. """ struct BarrierDistribution kind::Symbol - params::NTuple{3, Float64} + params::NTuple{3,Float64} end """ @@ -166,7 +166,7 @@ Monte Carlo simulation result. """ struct SimulationResult top_event_mean::Float64 - consequence_means::Dict{Symbol, Float64} + consequence_means::Dict{Symbol,Float64} samples::Vector{Float64} end @@ -174,15 +174,20 @@ end Compute chain probability with barrier reduction (independent assumptions). """ function chain_probability(chain::EventChain) - base = prod((e.probability for e in chain.events); init=1.0) - reduction = _combined_barrier_reduction(chain.barriers, chain.escalation_factors, ProbabilityModel(:independent)) + base = prod((e.probability for e in chain.events); init = 1.0) + reduction = _combined_barrier_reduction( + chain.barriers, + chain.escalation_factors, + ProbabilityModel(:independent), + ) base * reduction end function _effective_barrier(barrier::Barrier, factors::Vector{EscalationFactor}) base = clamp(barrier.effectiveness, 0.0, 1.0) degraded = base * (1.0 - clamp(barrier.degradation, 0.0, 1.0)) - factor_reduction = prod((1.0 - clamp(f.multiplier, 0.0, 1.0) for f in factors); init=1.0) + factor_reduction = + prod((1.0 - clamp(f.multiplier, 0.0, 1.0) for f in factors); init = 1.0) clamp(degraded * factor_reduction, 0.0, 1.0) end @@ -196,10 +201,12 @@ function _sample_distribution(dist::BarrierDistribution) low, mode, high = dist.params if !(low <= mode <= high) || low >= high - throw(ArgumentError( - "Triangular distribution requires low ≤ mode ≤ high and low < high. " * - "Got low=$low, mode=$mode, high=$high" - )) + throw( + ArgumentError( + "Triangular distribution requires low ≤ mode ≤ high and low < high. " * + "Got low=$low, mode=$mode, high=$high", + ), + ) end u = rand() @@ -214,30 +221,55 @@ function _sample_distribution(dist::BarrierDistribution) end end -function _apply_distributions(model::BowtieModel, dists::Dict{Symbol, BarrierDistribution}) +function _apply_distributions(model::BowtieModel, dists::Dict{Symbol,BarrierDistribution}) function sample_barrier(b::Barrier) if haskey(dists, b.name) dist = dists[b.name] sampled = _sample_distribution(dist) - return Barrier(b.name, sampled, b.kind, b.description, b.degradation, b.dependency) + return Barrier( + b.name, + sampled, + b.kind, + b.description, + b.degradation, + b.dependency, + ) end b end threats = ThreatPath[] for p in model.threat_paths - push!(threats, ThreatPath(p.threat, [sample_barrier(b) for b in p.barriers], p.escalation_factors)) + push!( + threats, + ThreatPath( + p.threat, + [sample_barrier(b) for b in p.barriers], + p.escalation_factors, + ), + ) end cons = ConsequencePath[] for p in model.consequence_paths - push!(cons, ConsequencePath(p.consequence, [sample_barrier(b) for b in p.barriers], p.escalation_factors)) + push!( + cons, + ConsequencePath( + p.consequence, + [sample_barrier(b) for b in p.barriers], + p.escalation_factors, + ), + ) end BowtieModel(model.hazard, model.top_event, threats, cons, model.probability_model) end -function _combined_barrier_reduction(barriers::Vector{Barrier}, factors::Vector{EscalationFactor}, model::ProbabilityModel) +function _combined_barrier_reduction( + barriers::Vector{Barrier}, + factors::Vector{EscalationFactor}, + model::ProbabilityModel, +) if isempty(barriers) return 1.0 end @@ -245,21 +277,26 @@ function _combined_barrier_reduction(barriers::Vector{Barrier}, factors::Vector{ effective = [_effective_barrier(b, factors) for b in barriers] if model.mode == :independent - return prod((1.0 - e for e in effective); init=1.0) + return prod((1.0 - e for e in effective); init = 1.0) elseif model.mode == :dependent - groups = Dict{Symbol, Vector{Float64}}() + groups = Dict{Symbol,Vector{Float64}}() for (i, b) in enumerate(barriers) dep = b.dependency == :none ? Symbol("barrier_$i") : b.dependency push!(get!(groups, dep, Float64[]), effective[i]) end combined = [minimum(vals) for vals in values(groups)] - return prod((1.0 - e for e in combined); init=1.0) + return prod((1.0 - e for e in combined); init = 1.0) else error("unknown probability model mode: $(model.mode)") end end -function _residual_probability(base::Float64, barriers::Vector{Barrier}, factors::Vector{EscalationFactor}, model::ProbabilityModel) +function _residual_probability( + base::Float64, + barriers::Vector{Barrier}, + factors::Vector{EscalationFactor}, + model::ProbabilityModel, +) reduction = _combined_barrier_reduction(barriers, factors, model) base * reduction end @@ -269,51 +306,71 @@ Evaluate a bowtie model and return a summary struct. """ struct BowtieSummary top_event_probability::Float64 - threat_residuals::Dict{Symbol, Float64} - consequence_probabilities::Dict{Symbol, Float64} - consequence_risks::Dict{Symbol, Float64} + threat_residuals::Dict{Symbol,Float64} + consequence_probabilities::Dict{Symbol,Float64} + consequence_risks::Dict{Symbol,Float64} end """ Compute residual probabilities, top event probability, and consequence risk. """ function evaluate(model::BowtieModel) - threat_residuals = Dict{Symbol, Float64}() + threat_residuals = Dict{Symbol,Float64}() residual_values = Float64[] for path in model.threat_paths base = clamp(path.threat.probability, 0.0, 1.0) - residual = _residual_probability(base, path.barriers, path.escalation_factors, model.probability_model) + residual = _residual_probability( + base, + path.barriers, + path.escalation_factors, + model.probability_model, + ) threat_residuals[path.threat.name] = residual push!(residual_values, residual) end - top_event_probability = isempty(residual_values) ? 0.0 : 1.0 - prod(1.0 .- residual_values) + top_event_probability = + isempty(residual_values) ? 0.0 : 1.0 - prod(1.0 .- residual_values) - consequence_probabilities = Dict{Symbol, Float64}() - consequence_risks = Dict{Symbol, Float64}() + consequence_probabilities = Dict{Symbol,Float64}() + consequence_risks = Dict{Symbol,Float64}() for path in model.consequence_paths - residual = _residual_probability(top_event_probability, path.barriers, path.escalation_factors, model.probability_model) + residual = _residual_probability( + top_event_probability, + path.barriers, + path.escalation_factors, + model.probability_model, + ) consequence_probabilities[path.consequence.name] = residual severity = clamp(path.consequence.severity, 0.0, 1.0) consequence_risks[path.consequence.name] = residual * severity end - BowtieSummary(top_event_probability, threat_residuals, consequence_probabilities, consequence_risks) + BowtieSummary( + top_event_probability, + threat_residuals, + consequence_probabilities, + consequence_risks, + ) end """ Run a Monte Carlo simulation with barrier effectiveness distributions. """ -function simulate(model::BowtieModel; samples::Int=1000, barrier_dists::Dict{Symbol, BarrierDistribution}=Dict{Symbol, BarrierDistribution}()) +function simulate( + model::BowtieModel; + samples::Int = 1000, + barrier_dists::Dict{Symbol,BarrierDistribution} = Dict{Symbol,BarrierDistribution}(), +) top_vals = Float64[] - cons_vals = Dict{Symbol, Vector{Float64}}() + cons_vals = Dict{Symbol,Vector{Float64}}() for path in model.consequence_paths cons_vals[path.consequence.name] = Float64[] end - for _ in 1:samples + for _ = 1:samples sampled = _apply_distributions(model, barrier_dists) summary = evaluate(sampled) push!(top_vals, summary.top_event_probability) @@ -322,7 +379,7 @@ function simulate(model::BowtieModel; samples::Int=1000, barrier_dists::Dict{Sym end end - cons_means = Dict{Symbol, Float64}() + cons_means = Dict{Symbol,Float64}() for (k, vals) in cons_vals cons_means[k] = isempty(vals) ? 0.0 : sum(vals) / length(vals) end @@ -333,27 +390,60 @@ end """ Sensitivity data for tornado charts (low/high values per barrier). """ -function sensitivity_tornado(model::BowtieModel; delta::Float64=0.1) +function sensitivity_tornado(model::BowtieModel; delta::Float64 = 0.1) base = evaluate(model).top_event_probability - results = Vector{Tuple{Symbol, Float64, Float64}}() + results = Vector{Tuple{Symbol,Float64,Float64}}() for (pidx, path) in enumerate(model.threat_paths) for (bidx, barrier) in enumerate(path.barriers) lower = clamp(barrier.effectiveness - delta, 0.0, 1.0) upper = clamp(barrier.effectiveness + delta, 0.0, 1.0) - low_barrier = Barrier(barrier.name, lower, barrier.kind, barrier.description, barrier.degradation, barrier.dependency) - high_barrier = Barrier(barrier.name, upper, barrier.kind, barrier.description, barrier.degradation, barrier.dependency) + low_barrier = Barrier( + barrier.name, + lower, + barrier.kind, + barrier.description, + barrier.degradation, + barrier.dependency, + ) + high_barrier = Barrier( + barrier.name, + upper, + barrier.kind, + barrier.description, + barrier.degradation, + barrier.dependency, + ) low_paths = deepcopy(model.threat_paths) high_paths = deepcopy(model.threat_paths) low_paths[pidx].barriers[bidx] = low_barrier high_paths[pidx].barriers[bidx] = high_barrier - low_model = BowtieModel(model.hazard, model.top_event, low_paths, model.consequence_paths, model.probability_model) - high_model = BowtieModel(model.hazard, model.top_event, high_paths, model.consequence_paths, model.probability_model) - - push!(results, (barrier.name, evaluate(low_model).top_event_probability, evaluate(high_model).top_event_probability)) + low_model = BowtieModel( + model.hazard, + model.top_event, + low_paths, + model.consequence_paths, + model.probability_model, + ) + high_model = BowtieModel( + model.hazard, + model.top_event, + high_paths, + model.consequence_paths, + model.probability_model, + ) + + push!( + results, + ( + barrier.name, + evaluate(low_model).top_event_probability, + evaluate(high_model).top_event_probability, + ), + ) end end @@ -364,8 +454,22 @@ function sensitivity_tornado(model::BowtieModel; delta::Float64=0.1) lower = clamp(barrier.effectiveness - delta, 0.0, 1.0) upper = clamp(barrier.effectiveness + delta, 0.0, 1.0) - low_barrier = Barrier(barrier.name, lower, barrier.kind, barrier.description, barrier.degradation, barrier.dependency) - high_barrier = Barrier(barrier.name, upper, barrier.kind, barrier.description, barrier.degradation, barrier.dependency) + low_barrier = Barrier( + barrier.name, + lower, + barrier.kind, + barrier.description, + barrier.degradation, + barrier.dependency, + ) + high_barrier = Barrier( + barrier.name, + upper, + barrier.kind, + barrier.description, + barrier.degradation, + barrier.dependency, + ) low_cons = deepcopy(model.consequence_paths) high_cons = deepcopy(model.consequence_paths) @@ -375,11 +479,25 @@ function sensitivity_tornado(model::BowtieModel; delta::Float64=0.1) high_barriers = copy(path.barriers) high_barriers[bidx] = high_barrier - low_cons[pidx] = ConsequencePath(path.consequence, low_barriers, path.escalation_factors) - high_cons[pidx] = ConsequencePath(path.consequence, high_barriers, path.escalation_factors) - - low_model = BowtieModel(model.hazard, model.top_event, model.threat_paths, low_cons, model.probability_model) - high_model = BowtieModel(model.hazard, model.top_event, model.threat_paths, high_cons, model.probability_model) + low_cons[pidx] = + ConsequencePath(path.consequence, low_barriers, path.escalation_factors) + high_cons[pidx] = + ConsequencePath(path.consequence, high_barriers, path.escalation_factors) + + low_model = BowtieModel( + model.hazard, + model.top_event, + model.threat_paths, + low_cons, + model.probability_model, + ) + high_model = BowtieModel( + model.hazard, + model.top_event, + model.threat_paths, + high_cons, + model.probability_model, + ) # For consequence barriers, measure impact on total risk (sum of consequence risks) low_risk = sum(values(evaluate(low_model).consequence_risks)) @@ -388,20 +506,26 @@ function sensitivity_tornado(model::BowtieModel; delta::Float64=0.1) end end - sort!(results, by=x -> abs(x[2] - x[3]), rev=true) + sort!(results, by = x -> abs(x[2] - x[3]), rev = true) results end """ Markdown report for a model and optional tornado data. """ -function report_markdown(model::BowtieModel; tornado_data::Vector{Tuple{Symbol, Float64, Float64}}=Tuple{Symbol, Float64, Float64}[]) +function report_markdown( + model::BowtieModel; + tornado_data::Vector{Tuple{Symbol,Float64,Float64}} = Tuple{Symbol,Float64,Float64}[], +) summary = evaluate(model) lines = String[] push!(lines, "# Bowtie Risk Report") push!(lines, "- Hazard: $(model.hazard.name)") push!(lines, "- Top event: $(model.top_event.name)") - push!(lines, "- Top event probability: $(round(summary.top_event_probability, digits=4))") + push!( + lines, + "- Top event probability: $(round(summary.top_event_probability, digits=4))", + ) push!(lines, "") push!(lines, "## Consequences") for (k, v) in summary.consequence_probabilities @@ -412,7 +536,10 @@ function report_markdown(model::BowtieModel; tornado_data::Vector{Tuple{Symbol, push!(lines, "") push!(lines, "## Sensitivity (Tornado)") for (name, low, high) in tornado_data - push!(lines, "- $(name): low=$(round(low, digits=4)) high=$(round(high, digits=4))") + push!( + lines, + "- $(name): low=$(round(low, digits=4)) high=$(round(high, digits=4))", + ) end end join(lines, "\n") @@ -421,16 +548,23 @@ end """ Write Markdown report to disk. """ -function write_report_markdown(path::AbstractString, model::BowtieModel; tornado_data::Vector{Tuple{Symbol, Float64, Float64}}=Tuple{Symbol, Float64, Float64}[]) +function write_report_markdown( + path::AbstractString, + model::BowtieModel; + tornado_data::Vector{Tuple{Symbol,Float64,Float64}} = Tuple{Symbol,Float64,Float64}[], +) open(path, "w") do io - write(io, report_markdown(model; tornado_data=tornado_data)) + write(io, report_markdown(model; tornado_data = tornado_data)) end nothing end """ Write tornado data to CSV.\n""" -function write_tornado_csv(path::AbstractString, data::Vector{Tuple{Symbol, Float64, Float64}}) +function write_tornado_csv( + path::AbstractString, + data::Vector{Tuple{Symbol,Float64,Float64}}, +) lines = ["barrier,low,high"] for (name, low, high) in data push!(lines, "$(name),$(low),$(high)") @@ -454,28 +588,60 @@ function template_model(name::Symbol) hazard = Hazard(:LossOfContainment, "Loss of containment from vessel") top = TopEvent(:ContainmentLost, "Containment is lost") threats = [ - ThreatPath(Threat(:Overpressure, 0.02, "Pressure exceeds design"), - [Barrier(:ReliefValve, 0.7, :preventive, "Relieves pressure", 0.1, :none)], - EscalationFactor[]), + ThreatPath( + Threat(:Overpressure, 0.02, "Pressure exceeds design"), + [Barrier(:ReliefValve, 0.7, :preventive, "Relieves pressure", 0.1, :none)], + EscalationFactor[], + ), ] consequences = [ - ConsequencePath(Consequence(:Release, 0.6, "Release to atmosphere"), - [Barrier(:GasDetection, 0.6, :mitigative, "Detects release", 0.0, :shared_power)], - EscalationFactor[]), + ConsequencePath( + Consequence(:Release, 0.6, "Release to atmosphere"), + [ + Barrier( + :GasDetection, + 0.6, + :mitigative, + "Detects release", + 0.0, + :shared_power, + ), + ], + EscalationFactor[], + ), ] - return BowtieModel(hazard, top, threats, consequences, ProbabilityModel(:independent)) + return BowtieModel( + hazard, + top, + threats, + consequences, + ProbabilityModel(:independent), + ) elseif name == :cyber_incident hazard = Hazard(:UnauthorizedAccess, "Unauthorized access to systems") top = TopEvent(:AccessGained, "Credentials compromised") threats = [ - ThreatPath(Threat(:Phishing, 0.08, "Credential phishing"), - [Barrier(:MFA, 0.8, :preventive, "Multi-factor auth", 0.0, :shared_identity)], - EscalationFactor[]), + ThreatPath( + Threat(:Phishing, 0.08, "Credential phishing"), + [ + Barrier( + :MFA, + 0.8, + :preventive, + "Multi-factor auth", + 0.0, + :shared_identity, + ), + ], + EscalationFactor[], + ), ] consequences = [ - ConsequencePath(Consequence(:DataLeak, 0.9, "Sensitive data exposure"), - [Barrier(:DLP, 0.5, :mitigative, "Data loss prevention", 0.0, :none)], - EscalationFactor[]), + ConsequencePath( + Consequence(:DataLeak, 0.9, "Sensitive data exposure"), + [Barrier(:DLP, 0.5, :mitigative, "Data loss prevention", 0.0, :none)], + EscalationFactor[], + ), ] return BowtieModel(hazard, top, threats, consequences, ProbabilityModel(:dependent)) else @@ -491,14 +657,16 @@ function model_schema() "type" => "object", "properties" => Dict( "name" => Dict("type" => "string"), - "effectiveness" => Dict("type" => "number", "minimum" => 0.0, "maximum" => 1.0), + "effectiveness" => + Dict("type" => "number", "minimum" => 0.0, "maximum" => 1.0), "kind" => Dict("type" => "string", "enum" => ["preventive", "mitigative"]), "description" => Dict("type" => "string"), - "degradation" => Dict("type" => "number", "minimum" => 0.0, "maximum" => 1.0), - "dependency" => Dict("type" => "string") + "degradation" => + Dict("type" => "number", "minimum" => 0.0, "maximum" => 1.0), + "dependency" => Dict("type" => "string"), ), "required" => ["name", "effectiveness", "kind"], - "additionalProperties" => false + "additionalProperties" => false, ) Dict( @@ -510,19 +678,19 @@ function model_schema() "type" => "object", "properties" => Dict( "name" => Dict("type" => "string"), - "description" => Dict("type" => "string") + "description" => Dict("type" => "string"), ), "required" => ["name", "description"], - "additionalProperties" => false + "additionalProperties" => false, ), "top_event" => Dict( "type" => "object", "properties" => Dict( "name" => Dict("type" => "string"), - "description" => Dict("type" => "string") + "description" => Dict("type" => "string"), ), "required" => ["name", "description"], - "additionalProperties" => false + "additionalProperties" => false, ), "threat_paths" => Dict( "type" => "array", @@ -533,18 +701,22 @@ function model_schema() "type" => "object", "properties" => Dict( "name" => Dict("type" => "string"), - "probability" => Dict("type" => "number", "minimum" => 0.0, "maximum" => 1.0), - "description" => Dict("type" => "string") + "probability" => Dict( + "type" => "number", + "minimum" => 0.0, + "maximum" => 1.0, + ), + "description" => Dict("type" => "string"), ), "required" => ["name", "probability"], - "additionalProperties" => false + "additionalProperties" => false, ), "barriers" => Dict("type" => "array", "items" => barrier_schema), - "escalation_factors" => Dict("type" => "array") + "escalation_factors" => Dict("type" => "array"), ), "required" => ["threat", "barriers"], - "additionalProperties" => false - ) + "additionalProperties" => false, + ), ), "consequence_paths" => Dict( "type" => "array", @@ -555,30 +727,43 @@ function model_schema() "type" => "object", "properties" => Dict( "name" => Dict("type" => "string"), - "severity" => Dict("type" => "number", "minimum" => 0.0, "maximum" => 1.0), - "description" => Dict("type" => "string") + "severity" => Dict( + "type" => "number", + "minimum" => 0.0, + "maximum" => 1.0, + ), + "description" => Dict("type" => "string"), ), "required" => ["name", "severity"], - "additionalProperties" => false + "additionalProperties" => false, ), "barriers" => Dict("type" => "array", "items" => barrier_schema), - "escalation_factors" => Dict("type" => "array") + "escalation_factors" => Dict("type" => "array"), ), "required" => ["consequence", "barriers"], - "additionalProperties" => false - ) + "additionalProperties" => false, + ), ), "probability_model" => Dict( "type" => "object", "properties" => Dict( - "mode" => Dict("type" => "string", "enum" => ["independent", "dependent"]) + "mode" => Dict( + "type" => "string", + "enum" => ["independent", "dependent"], + ), ), "required" => ["mode"], - "additionalProperties" => false + "additionalProperties" => false, ), ), - "required" => ["hazard", "top_event", "threat_paths", "consequence_paths", "probability_model"], - "additionalProperties" => false + "required" => [ + "hazard", + "top_event", + "threat_paths", + "consequence_paths", + "probability_model", + ], + "additionalProperties" => false, ) end @@ -597,13 +782,13 @@ Load a simple CSV file into a vector of dictionaries.\nFormat: header row with c """ function load_simple_csv(path::AbstractString) lines = readlines(path) - isempty(lines) && return Dict{String, String}[] + isempty(lines) && return Dict{String,String}[] header = split(strip(lines[1]), ',') - rows = Dict{String, String}[] + rows = Dict{String,String}[] for line in lines[2:end] strip(line) == "" && continue values = split(strip(line), ',') - row = Dict{String, String}() + row = Dict{String,String}() for (i, key) in enumerate(header) row[key] = i <= length(values) ? values[i] : "" end @@ -701,14 +886,22 @@ end Write a bowtie model to JSON. """ function write_model_json(path::AbstractString, model::BowtieModel) - obj = Dict{String, Any}() - obj["hazard"] = Dict("name" => String(model.hazard.name), "description" => model.hazard.description) - obj["top_event"] = Dict("name" => String(model.top_event.name), "description" => model.top_event.description) + obj = Dict{String,Any}() + obj["hazard"] = + Dict("name" => String(model.hazard.name), "description" => model.hazard.description) + obj["top_event"] = Dict( + "name" => String(model.top_event.name), + "description" => model.top_event.description, + ) obj["probability_model"] = Dict("mode" => String(model.probability_model.mode)) obj["threat_paths"] = [ Dict( - "threat" => Dict("name" => String(p.threat.name), "probability" => p.threat.probability, "description" => p.threat.description), + "threat" => Dict( + "name" => String(p.threat.name), + "probability" => p.threat.probability, + "description" => p.threat.description, + ), "barriers" => [ Dict( "name" => String(b.name), @@ -720,14 +913,22 @@ function write_model_json(path::AbstractString, model::BowtieModel) ) for b in p.barriers ], "escalation_factors" => [ - Dict("name" => String(f.name), "multiplier" => f.multiplier, "description" => f.description) for f in p.escalation_factors + Dict( + "name" => String(f.name), + "multiplier" => f.multiplier, + "description" => f.description, + ) for f in p.escalation_factors ], ) for p in model.threat_paths ] obj["consequence_paths"] = [ Dict( - "consequence" => Dict("name" => String(p.consequence.name), "severity" => p.consequence.severity, "description" => p.consequence.description), + "consequence" => Dict( + "name" => String(p.consequence.name), + "severity" => p.consequence.severity, + "description" => p.consequence.description, + ), "barriers" => [ Dict( "name" => String(b.name), @@ -739,7 +940,11 @@ function write_model_json(path::AbstractString, model::BowtieModel) ) for b in p.barriers ], "escalation_factors" => [ - Dict("name" => String(f.name), "multiplier" => f.multiplier, "description" => f.description) for f in p.escalation_factors + Dict( + "name" => String(f.name), + "multiplier" => f.multiplier, + "description" => f.description, + ) for f in p.escalation_factors ], ) for p in model.consequence_paths ] @@ -753,10 +958,18 @@ end # Typed accessors for JSON3 values. JSON3 yields a Union of JSON types, so each # value is checked once here; a malformed model file raises ArgumentError rather # than a MethodError deep inside the constructors. -_json_obj(x)::JSON3.Object = x isa JSON3.Object ? x : throw(ArgumentError("model JSON: expected an object, got $(typeof(x))")) -_json_arr(x)::AbstractVector = x isa AbstractVector ? x : throw(ArgumentError("model JSON: expected an array, got $(typeof(x))")) -_json_str(x)::String = x isa AbstractString ? String(x) : throw(ArgumentError("model JSON: expected a string, got $(typeof(x))")) -_json_num(x)::Float64 = x isa Real ? Float64(x) : throw(ArgumentError("model JSON: expected a number, got $(typeof(x))")) +_json_obj(x)::JSON3.Object = + x isa JSON3.Object ? x : + throw(ArgumentError("model JSON: expected an object, got $(typeof(x))")) +_json_arr(x)::AbstractVector = + x isa AbstractVector ? x : + throw(ArgumentError("model JSON: expected an array, got $(typeof(x))")) +_json_str(x)::String = + x isa AbstractString ? String(x) : + throw(ArgumentError("model JSON: expected a string, got $(typeof(x))")) +_json_num(x)::Float64 = + x isa Real ? Float64(x) : + throw(ArgumentError("model JSON: expected a number, got $(typeof(x))")) _json_sym(x)::Symbol = Symbol(_json_str(x)) _json_field(o, key::String) = _json_obj(o)[Symbol(key)] @@ -772,11 +985,13 @@ function _read_barrier(b) end function _read_factors(list) - [EscalationFactor( - _json_sym(_json_field(f, "name")), - _json_num(_json_field(f, "multiplier")), - _json_str(_json_field(f, "description")), - ) for f in _json_arr(list)] + [ + EscalationFactor( + _json_sym(_json_field(f, "name")), + _json_num(_json_field(f, "multiplier")), + _json_str(_json_field(f, "description")), + ) for f in _json_arr(list) + ] end function _read_barriers(list) @@ -790,9 +1005,17 @@ function read_model_json(path::AbstractString) obj = _json_obj(JSON3.read(read(path, String))) hz = _json_obj(_json_field(obj, "hazard")) te = _json_obj(_json_field(obj, "top_event")) - hazard = Hazard(_json_sym(_json_field(hz, "name")), _json_str(_json_field(hz, "description"))) - top_event = TopEvent(_json_sym(_json_field(te, "name")), _json_str(_json_field(te, "description"))) - model = ProbabilityModel(_json_sym(_json_field(_json_obj(_json_field(obj, "probability_model")), "mode"))) + hazard = Hazard( + _json_sym(_json_field(hz, "name")), + _json_str(_json_field(hz, "description")), + ) + top_event = TopEvent( + _json_sym(_json_field(te, "name")), + _json_str(_json_field(te, "description")), + ) + model = ProbabilityModel( + _json_sym(_json_field(_json_obj(_json_field(obj, "probability_model")), "mode")), + ) threat_paths = ThreatPath[] for p in _json_arr(_json_field(obj, "threat_paths")) @@ -802,11 +1025,14 @@ function read_model_json(path::AbstractString) _json_num(_json_field(t, "probability")), _json_str(_json_field(t, "description")), ) - push!(threat_paths, ThreatPath( - threat, - _read_barriers(_json_field(p, "barriers")), - _read_factors(_json_field(p, "escalation_factors")), - )) + push!( + threat_paths, + ThreatPath( + threat, + _read_barriers(_json_field(p, "barriers")), + _read_factors(_json_field(p, "escalation_factors")), + ), + ) end consequence_paths = ConsequencePath[] @@ -817,11 +1043,14 @@ function read_model_json(path::AbstractString) _json_num(_json_field(c, "severity")), _json_str(_json_field(c, "description")), ) - push!(consequence_paths, ConsequencePath( - consequence, - _read_barriers(_json_field(p, "barriers")), - _read_factors(_json_field(p, "escalation_factors")), - )) + push!( + consequence_paths, + ConsequencePath( + consequence, + _read_barriers(_json_field(p, "barriers")), + _read_factors(_json_field(p, "escalation_factors")), + ), + ) end BowtieModel(hazard, top_event, threat_paths, consequence_paths, model) diff --git a/src/backends/abstract.jl b/src/backends/abstract.jl index f50ae59..e5473bf 100644 --- a/src/backends/abstract.jl +++ b/src/backends/abstract.jl @@ -27,24 +27,36 @@ function backend_coprocessor_correlation_matrix end function backend_coprocessor_probability_sample end # Self-healing fallback generation for coprocessor hooks -generate_self_healing_hooks(@__MODULE__, [ - (:backend_coprocessor_monte_carlo_step, :backend_monte_carlo_step), - (:backend_coprocessor_risk_aggregate, :backend_risk_aggregate), - (:backend_coprocessor_barrier_eval, :backend_barrier_eval), - (:backend_coprocessor_correlation_matrix, :backend_correlation_matrix), - (:backend_coprocessor_probability_sample, :backend_probability_sample), -]) +generate_self_healing_hooks( + @__MODULE__, + [ + (:backend_coprocessor_monte_carlo_step, :backend_monte_carlo_step), + (:backend_coprocessor_risk_aggregate, :backend_risk_aggregate), + (:backend_coprocessor_barrier_eval, :backend_barrier_eval), + (:backend_coprocessor_correlation_matrix, :backend_correlation_matrix), + (:backend_coprocessor_probability_sample, :backend_probability_sample), + ], +) # Dispatch: CoprocessorBackend -> coprocessor hook -backend_monte_carlo_step(b::CoprocessorBackend, args...) = backend_coprocessor_monte_carlo_step(b, args...) -backend_risk_aggregate(b::CoprocessorBackend, args...) = backend_coprocessor_risk_aggregate(b, args...) -backend_barrier_eval(b::CoprocessorBackend, args...) = backend_coprocessor_barrier_eval(b, args...) -backend_correlation_matrix(b::CoprocessorBackend, args...) = backend_coprocessor_correlation_matrix(b, args...) -backend_probability_sample(b::CoprocessorBackend, args...) = backend_coprocessor_probability_sample(b, args...) +backend_monte_carlo_step(b::CoprocessorBackend, args...) = + backend_coprocessor_monte_carlo_step(b, args...) +backend_risk_aggregate(b::CoprocessorBackend, args...) = + backend_coprocessor_risk_aggregate(b, args...) +backend_barrier_eval(b::CoprocessorBackend, args...) = + backend_coprocessor_barrier_eval(b, args...) +backend_correlation_matrix(b::CoprocessorBackend, args...) = + backend_coprocessor_correlation_matrix(b, args...) +backend_probability_sample(b::CoprocessorBackend, args...) = + backend_coprocessor_probability_sample(b, args...) # Dispatch: GPUBackend -> Julia fallback (extensions override) -backend_monte_carlo_step(b::GPUBackend, args...) = backend_monte_carlo_step(JuliaBackend(), args...) -backend_risk_aggregate(b::GPUBackend, args...) = backend_risk_aggregate(JuliaBackend(), args...) +backend_monte_carlo_step(b::GPUBackend, args...) = + backend_monte_carlo_step(JuliaBackend(), args...) +backend_risk_aggregate(b::GPUBackend, args...) = + backend_risk_aggregate(JuliaBackend(), args...) backend_barrier_eval(b::GPUBackend, args...) = backend_barrier_eval(JuliaBackend(), args...) -backend_correlation_matrix(b::GPUBackend, args...) = backend_correlation_matrix(JuliaBackend(), args...) -backend_probability_sample(b::GPUBackend, args...) = backend_probability_sample(JuliaBackend(), args...) +backend_correlation_matrix(b::GPUBackend, args...) = + backend_correlation_matrix(JuliaBackend(), args...) +backend_probability_sample(b::GPUBackend, args...) = + backend_probability_sample(JuliaBackend(), args...) diff --git a/test/e2e_test.jl b/test/e2e_test.jl index 69cc86b..6d2c4b5 100644 --- a/test/e2e_test.jl +++ b/test/e2e_test.jl @@ -18,8 +18,22 @@ using BowtieRisk ThreatPath( Threat(:Phishing, 0.15, "Spear phishing email"), [ - Barrier(:EmailFilter, 0.7, :preventive, "Email gateway filter", 0.05, :none), - Barrier(:UserTraining, 0.5, :preventive, "Security awareness training", 0.1, :shared_training), + Barrier( + :EmailFilter, + 0.7, + :preventive, + "Email gateway filter", + 0.05, + :none, + ), + Barrier( + :UserTraining, + 0.5, + :preventive, + "Security awareness training", + 0.1, + :shared_training, + ), ], [EscalationFactor(:RemoteWork, 0.2, "Reduced physical oversight")], ), @@ -36,7 +50,14 @@ using BowtieRisk Consequence(:DataLoss, 0.9, "Customer PII exfiltrated"), [ Barrier(:DLP, 0.6, :mitigative, "Data loss prevention", 0.0, :none), - Barrier(:Encryption, 0.9, :mitigative, "Data at rest encryption", 0.0, :none), + Barrier( + :Encryption, + 0.9, + :mitigative, + "Data at rest encryption", + 0.0, + :none, + ), ], EscalationFactor[], ), @@ -48,8 +69,13 @@ using BowtieRisk ] # 4. Build model - model = BowtieModel(hazard, top_event, threat_paths, consequence_paths, - ProbabilityModel(:independent)) + model = BowtieModel( + hazard, + top_event, + threat_paths, + consequence_paths, + ProbabilityModel(:independent), + ) # 5. Evaluate deterministically summary = evaluate(model) @@ -64,19 +90,19 @@ using BowtieRisk # 6. Monte Carlo simulation dists = Dict( - :EmailFilter => BarrierDistribution(:beta, (5.0, 2.0, 0.0)), - :UserTraining => BarrierDistribution(:triangular, (0.2, 0.5, 0.8)), - :Encryption => BarrierDistribution(:beta, (8.0, 1.0, 0.0)), + :EmailFilter => BarrierDistribution(:beta, (5.0, 2.0, 0.0)), + :UserTraining => BarrierDistribution(:triangular, (0.2, 0.5, 0.8)), + :Encryption => BarrierDistribution(:beta, (8.0, 1.0, 0.0)), ) - sim = simulate(model; samples=200, barrier_dists=dists) + sim = simulate(model; samples = 200, barrier_dists = dists) @test sim.top_event_mean >= 0.0 @test length(sim.samples) == 200 # 7. Sensitivity - tornado = sensitivity_tornado(model; delta=0.1) + tornado = sensitivity_tornado(model; delta = 0.1) @test !isempty(tornado) impacts = [abs(t[3] - t[2]) for t in tornado] - @test issorted(impacts, rev=true) + @test issorted(impacts, rev = true) # 8. Export round-trip dir = mktempdir() @@ -88,7 +114,7 @@ using BowtieRisk # 9. Diagram exports mermaid = to_mermaid(model) - dot = to_graphviz(model) + dot = to_graphviz(model) @test occursin("DataExfiltration", mermaid) @test occursin("digraph", dot) end @@ -99,7 +125,8 @@ using BowtieRisk # Unknown probability model bad_model = BowtieModel( - Hazard(:H, "Hazard"), TopEvent(:T, "Top"), + Hazard(:H, "Hazard"), + TopEvent(:T, "Top"), [ThreatPath(Threat(:X, 0.1, "Threat"), Barrier[], EscalationFactor[])], [ConsequencePath(Consequence(:C, 0.5, "Cons"), Barrier[], EscalationFactor[])], ProbabilityModel(:bogus_mode), @@ -108,13 +135,23 @@ using BowtieRisk # Unknown barrier distribution in simulation simple_model = BowtieModel( - Hazard(:H, "Hazard"), TopEvent(:T, "Top"), - [ThreatPath(Threat(:X, 0.1, "Threat"), [Barrier(:B, 0.5, :preventive, "B", 0.0, :none)], EscalationFactor[])], + Hazard(:H, "Hazard"), + TopEvent(:T, "Top"), + [ + ThreatPath( + Threat(:X, 0.1, "Threat"), + [Barrier(:B, 0.5, :preventive, "B", 0.0, :none)], + EscalationFactor[], + ), + ], [ConsequencePath(Consequence(:C, 0.5, "Cons"), Barrier[], EscalationFactor[])], ProbabilityModel(:independent), ) - @test_throws ErrorException simulate(simple_model; samples=5, - barrier_dists=Dict(:B => BarrierDistribution(:no_such_dist, (0.5, 0.5, 0.5)))) + @test_throws ErrorException simulate( + simple_model; + samples = 5, + barrier_dists = Dict(:B => BarrierDistribution(:no_such_dist, (0.5, 0.5, 0.5))), + ) end @testset "Round-trip consistency: JSON serialisation" begin diff --git a/test/property_test.jl b/test/property_test.jl index b21cfa1..c515988 100644 --- a/test/property_test.jl +++ b/test/property_test.jl @@ -9,20 +9,26 @@ using BowtieRisk @testset "Property-Based Tests" begin @testset "Invariant: top event probability in [0, 1]" begin - for _ in 1:50 + for _ = 1:50 n_threats = rand(1:5) threat_paths = [ ThreatPath( Threat(Symbol("T$i"), rand(), "Threat $i"), [Barrier(Symbol("B$i"), rand(), :preventive, "B", 0.0, :none)], EscalationFactor[], - ) - for i in 1:n_threats + ) for i = 1:n_threats ] model = BowtieModel( - Hazard(:H, "H"), TopEvent(:T, "T"), + Hazard(:H, "H"), + TopEvent(:T, "T"), threat_paths, - [ConsequencePath(Consequence(:C, rand(), "C"), Barrier[], EscalationFactor[])], + [ + ConsequencePath( + Consequence(:C, rand(), "C"), + Barrier[], + EscalationFactor[], + ), + ], ProbabilityModel(:independent), ) s = evaluate(model) @@ -31,20 +37,30 @@ using BowtieRisk end @testset "Invariant: adding a perfect barrier cannot increase risk" begin - for _ in 1:50 + for _ = 1:50 p_threat = rand(0.01:0.01:0.5) threat = Threat(:T, p_threat, "Threat") - cons = ConsequencePath(Consequence(:C, 0.5, "C"), Barrier[], EscalationFactor[]) + cons = ConsequencePath(Consequence(:C, 0.5, "C"), Barrier[], EscalationFactor[]) model_bare = BowtieModel( - Hazard(:H, "H"), TopEvent(:Top, "Top"), + Hazard(:H, "H"), + TopEvent(:Top, "Top"), [ThreatPath(threat, Barrier[], EscalationFactor[])], - [cons], ProbabilityModel(:independent), + [cons], + ProbabilityModel(:independent), ) model_with = BowtieModel( - Hazard(:H, "H"), TopEvent(:Top, "Top"), - [ThreatPath(threat, [Barrier(:B, 1.0, :preventive, "Perfect", 0.0, :none)], EscalationFactor[])], - [cons], ProbabilityModel(:independent), + Hazard(:H, "H"), + TopEvent(:Top, "Top"), + [ + ThreatPath( + threat, + [Barrier(:B, 1.0, :preventive, "Perfect", 0.0, :none)], + EscalationFactor[], + ), + ], + [cons], + ProbabilityModel(:independent), ) s_bare = evaluate(model_bare) @@ -57,18 +73,18 @@ using BowtieRisk end @testset "Invariant: consequence risks are non-negative" begin - for _ in 1:50 + for _ = 1:50 n_cons = rand(1:4) cons_paths = [ ConsequencePath( Consequence(Symbol("C$i"), rand(), "C $i"), [Barrier(Symbol("M$i"), rand(), :mitigative, "M", 0.0, :none)], EscalationFactor[], - ) - for i in 1:n_cons + ) for i = 1:n_cons ] model = BowtieModel( - Hazard(:H, "H"), TopEvent(:T, "T"), + Hazard(:H, "H"), + TopEvent(:T, "T"), [ThreatPath(Threat(:T1, rand(), "T1"), Barrier[], EscalationFactor[])], cons_paths, ProbabilityModel(:independent), @@ -81,7 +97,7 @@ using BowtieRisk end @testset "Invariant: more barriers cannot increase threat residual" begin - for _ in 1:50 + for _ = 1:50 p = rand(0.05:0.05:0.5) threat = Threat(:T, p, "Threat") eff1 = rand(0.3:0.1:0.9) @@ -89,15 +105,33 @@ using BowtieRisk cons = ConsequencePath(Consequence(:C, 0.5, "C"), Barrier[], EscalationFactor[]) model_one = BowtieModel( - Hazard(:H, "H"), TopEvent(:Top, "Top"), - [ThreatPath(threat, [Barrier(:B1, eff1, :preventive, "B1", 0.0, :none)], EscalationFactor[])], - [cons], ProbabilityModel(:independent), + Hazard(:H, "H"), + TopEvent(:Top, "Top"), + [ + ThreatPath( + threat, + [Barrier(:B1, eff1, :preventive, "B1", 0.0, :none)], + EscalationFactor[], + ), + ], + [cons], + ProbabilityModel(:independent), ) model_two = BowtieModel( - Hazard(:H, "H"), TopEvent(:Top, "Top"), - [ThreatPath(threat, [Barrier(:B1, eff1, :preventive, "B1", 0.0, :none), - Barrier(:B2, eff2, :preventive, "B2", 0.0, :none)], EscalationFactor[])], - [cons], ProbabilityModel(:independent), + Hazard(:H, "H"), + TopEvent(:Top, "Top"), + [ + ThreatPath( + threat, + [ + Barrier(:B1, eff1, :preventive, "B1", 0.0, :none), + Barrier(:B2, eff2, :preventive, "B2", 0.0, :none), + ], + EscalationFactor[], + ), + ], + [cons], + ProbabilityModel(:independent), ) s_one = evaluate(model_one) @@ -109,17 +143,28 @@ using BowtieRisk @testset "Invariant: simulation mean converges toward deterministic estimate" begin # Use a fixed, simple model and verify mean is in a reasonable range - for _ in 1:50 + for _ = 1:50 p = rand(0.1:0.05:0.4) eff = rand(0.3:0.1:0.8) model = BowtieModel( - Hazard(:H, "H"), TopEvent(:T, "T"), - [ThreatPath(Threat(:T1, p, "T1"), [Barrier(:B1, eff, :preventive, "B1", 0.0, :none)], EscalationFactor[])], + Hazard(:H, "H"), + TopEvent(:T, "T"), + [ + ThreatPath( + Threat(:T1, p, "T1"), + [Barrier(:B1, eff, :preventive, "B1", 0.0, :none)], + EscalationFactor[], + ), + ], [ConsequencePath(Consequence(:C, 0.5, "C"), Barrier[], EscalationFactor[])], ProbabilityModel(:independent), ) det = evaluate(model) - sim = simulate(model; samples=50, barrier_dists=Dict{Symbol,BarrierDistribution}()) + sim = simulate( + model; + samples = 50, + barrier_dists = Dict{Symbol,BarrierDistribution}(), + ) # Nominal simulation should match deterministic within ±0.3 @test abs(sim.top_event_mean - det.top_event_probability) < 0.3 end diff --git a/test/runtests.jl b/test/runtests.jl index 45d80a2..79a8994 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -19,30 +19,29 @@ end hazard = Hazard(:Hazard, "Test hazard") top_event = TopEvent(:Top, "Top event") - threats = [ - Threat(:T1, 0.2, "Threat 1"), - Threat(:T2, 0.1, "Threat 2"), - ] + threats = [Threat(:T1, 0.2, "Threat 1"), Threat(:T2, 0.1, "Threat 2")] preventive = [ Barrier(:B1, 0.5, :preventive, "Barrier 1", 0.0, :none), Barrier(:B2, 0.25, :preventive, "Barrier 2", 0.0, :none), ] - consequences = [ - Consequence(:C1, 0.8, "Consequence 1"), - Consequence(:C2, 0.4, "Consequence 2"), - ] + consequences = + [Consequence(:C1, 0.8, "Consequence 1"), Consequence(:C2, 0.4, "Consequence 2")] - mitigative = [ - Barrier(:M1, 0.5, :mitigative, "Barrier 3", 0.1, :shared_power), - ] + mitigative = [Barrier(:M1, 0.5, :mitigative, "Barrier 3", 0.1, :shared_power)] model = BowtieModel( hazard, top_event, - [ThreatPath(threats[1], [preventive[1]], EscalationFactor[]), ThreatPath(threats[2], [preventive[2]], EscalationFactor[])], - [ConsequencePath(consequences[1], [mitigative[1]], EscalationFactor[]), ConsequencePath(consequences[2], Barrier[], EscalationFactor[])], + [ + ThreatPath(threats[1], [preventive[1]], EscalationFactor[]), + ThreatPath(threats[2], [preventive[2]], EscalationFactor[]), + ], + [ + ConsequencePath(consequences[1], [mitigative[1]], EscalationFactor[]), + ConsequencePath(consequences[2], Barrier[], EscalationFactor[]), + ], ProbabilityModel(:independent), ) @@ -87,7 +86,11 @@ end summary_no_barriers = evaluate(model_no_barriers) @test summary_no_barriers.top_event_probability ≈ threats[1].probability - chain = EventChain([Event(:E1, 0.2, "Event 1"), Event(:E2, 0.5, "Event 2")], [mitigative[1]], EscalationFactor[]) + chain = EventChain( + [Event(:E1, 0.2, "Event 1"), Event(:E2, 0.5, "Event 2")], + [mitigative[1]], + EscalationFactor[], + ) @test chain_probability(chain) ≈ 0.2 * 0.5 * (1.0 - (0.5 * 0.9)) dependent = BowtieModel( @@ -108,22 +111,22 @@ end write_model_json(path, model) model2 = read_model_json(path) @test model2.top_event.name == :Top - rm(path, force=true) + rm(path, force = true) - dists = Dict{Symbol, BarrierDistribution}( + dists = Dict{Symbol,BarrierDistribution}( :B1 => BarrierDistribution(:beta, (2.0, 5.0, 0.0)), :M1 => BarrierDistribution(:triangular, (0.2, 0.5, 0.9)), ) - sim = simulate(model; samples=20, barrier_dists=dists) + sim = simulate(model; samples = 20, barrier_dists = dists) @test sim.top_event_mean >= 0.0 @test haskey(sim.consequence_means, :C1) # Edge case: Monte Carlo with minimal samples - sim_min = simulate(model; samples=5, barrier_dists=dists) + sim_min = simulate(model; samples = 5, barrier_dists = dists) @test sim_min.top_event_mean >= 0.0 # Edge case: Monte Carlo with many samples - sim_many = simulate(model; samples=100, barrier_dists=dists) + sim_many = simulate(model; samples = 100, barrier_dists = dists) @test sim_many.top_event_mean >= 0.0 @test abs(sim_many.top_event_mean - summary.top_event_probability) < 0.5 @@ -131,16 +134,17 @@ end # Future enhancement: add top_event_std field # Edge case: Simulation with no distributions (uses nominal values) - sim_nominal = simulate(model; samples=20, barrier_dists=Dict{Symbol, BarrierDistribution}()) + sim_nominal = + simulate(model; samples = 20, barrier_dists = Dict{Symbol,BarrierDistribution}()) @test sim_nominal.top_event_mean >= 0.0 - tornado = sensitivity_tornado(model; delta=0.1) + tornado = sensitivity_tornado(model; delta = 0.1) @test !isempty(tornado) # Edge case: Sensitivity with different delta values - tornado_small = sensitivity_tornado(model; delta=0.05) + tornado_small = sensitivity_tornado(model; delta = 0.05) @test !isempty(tornado_small) - tornado_large = sensitivity_tornado(model; delta=0.2) + tornado_large = sensitivity_tornado(model; delta = 0.2) @test !isempty(tornado_large) # Test that tornado contains expected barrier names @@ -154,14 +158,14 @@ end @test impact1 >= impact_last end report_path = joinpath(@__DIR__, "report.md") - write_report_markdown(report_path, model; tornado_data=tornado) + write_report_markdown(report_path, model; tornado_data = tornado) @test isfile(report_path) - rm(report_path, force=true) + rm(report_path, force = true) csv_path = joinpath(@__DIR__, "tornado.csv") write_tornado_csv(csv_path, tornado) @test isfile(csv_path) - rm(csv_path, force=true) + rm(csv_path, force = true) templ = template_model(:process_safety) @test templ.top_event.name == :ContainmentLost @@ -177,7 +181,7 @@ end schema_path = joinpath(@__DIR__, "schema.json") write_schema_json(schema_path) @test isfile(schema_path) - rm(schema_path, force=true) + rm(schema_path, force = true) simple_path = joinpath(@__DIR__, "simple.csv") open(simple_path, "w") do io @@ -185,7 +189,7 @@ end end rows = load_simple_csv(simple_path) @test rows[1]["a"] == "1" - rm(simple_path, force=true) + rm(simple_path, force = true) # ==================================================================== # Point-to-point tests (individual function coverage gaps) @@ -232,16 +236,22 @@ end barrier_degraded = Barrier(:valve, 0.9, :preventive, "Relief valve", 0.3, :none) threat = Threat(:leak, 0.1, "Leak") - cons = ConsequencePath(Consequence(:C, 0.5, "Consequence"), Barrier[], EscalationFactor[]) + cons = ConsequencePath( + Consequence(:C, 0.5, "Consequence"), + Barrier[], + EscalationFactor[], + ) model_fresh = BowtieModel( - Hazard(:H, "Hazard"), TopEvent(:T, "Top"), + Hazard(:H, "Hazard"), + TopEvent(:T, "Top"), [ThreatPath(threat, [barrier_fresh], EscalationFactor[])], [cons], ProbabilityModel(:independent), ) model_degraded = BowtieModel( - Hazard(:H, "Hazard"), TopEvent(:T, "Top"), + Hazard(:H, "Hazard"), + TopEvent(:T, "Top"), [ThreatPath(threat, [barrier_degraded], EscalationFactor[])], [cons], ProbabilityModel(:independent), @@ -265,16 +275,22 @@ end b3 = Barrier(:alarm, 0.9, :preventive, "Alarm", 0.0, :none) threat = Threat(:fault, 0.1, "Equipment fault") - cons = ConsequencePath(Consequence(:C, 0.5, "Consequence"), Barrier[], EscalationFactor[]) + cons = ConsequencePath( + Consequence(:C, 0.5, "Consequence"), + Barrier[], + EscalationFactor[], + ) model_indep = BowtieModel( - Hazard(:H, "Hazard"), TopEvent(:T, "Top"), + Hazard(:H, "Hazard"), + TopEvent(:T, "Top"), [ThreatPath(threat, [b1, b2, b3], EscalationFactor[])], [cons], ProbabilityModel(:independent), ) model_dep = BowtieModel( - Hazard(:H, "Hazard"), TopEvent(:T, "Top"), + Hazard(:H, "Hazard"), + TopEvent(:T, "Top"), [ThreatPath(threat, [b1, b2, b3], EscalationFactor[])], [cons], ProbabilityModel(:dependent), @@ -300,8 +316,15 @@ end # Unknown probability model mode should throw during evaluate bad_model = BowtieModel( - Hazard(:H, "Hazard"), TopEvent(:T, "Top"), - [ThreatPath(Threat(:X, 0.1, "Threat"), [Barrier(:B, 0.5, :preventive, "B", 0.0, :none)], EscalationFactor[])], + Hazard(:H, "Hazard"), + TopEvent(:T, "Top"), + [ + ThreatPath( + Threat(:X, 0.1, "Threat"), + [Barrier(:B, 0.5, :preventive, "B", 0.0, :none)], + EscalationFactor[], + ), + ], [ConsequencePath(Consequence(:C, 0.5, "Cons"), Barrier[], EscalationFactor[])], ProbabilityModel(:bogus), ) @@ -310,16 +333,31 @@ end # Unknown distribution kind should throw during simulation bad_dist = Dict(:B => BarrierDistribution(:unknown_dist, (0.5, 0.5, 0.5))) simple_model = BowtieModel( - Hazard(:H, "Hazard"), TopEvent(:T, "Top"), - [ThreatPath(Threat(:X, 0.1, "Threat"), [Barrier(:B, 0.5, :preventive, "B", 0.0, :none)], EscalationFactor[])], + Hazard(:H, "Hazard"), + TopEvent(:T, "Top"), + [ + ThreatPath( + Threat(:X, 0.1, "Threat"), + [Barrier(:B, 0.5, :preventive, "B", 0.0, :none)], + EscalationFactor[], + ), + ], [ConsequencePath(Consequence(:C, 0.5, "Cons"), Barrier[], EscalationFactor[])], ProbabilityModel(:independent), ) - @test_throws ErrorException simulate(simple_model; samples=5, barrier_dists=bad_dist) + @test_throws ErrorException simulate( + simple_model; + samples = 5, + barrier_dists = bad_dist, + ) # Invalid triangular distribution parameters should throw bad_tri_dist = Dict(:B => BarrierDistribution(:triangular, (0.9, 0.1, 0.5))) - @test_throws ArgumentError simulate(simple_model; samples=5, barrier_dists=bad_tri_dist) + @test_throws ArgumentError simulate( + simple_model; + samples = 5, + barrier_dists = bad_tri_dist, + ) end @testset "CSV edge cases" begin @@ -330,14 +368,14 @@ end open(empty_path, "w") do io write(io, "") end - @test load_simple_csv(empty_path) == Dict{String, String}[] + @test load_simple_csv(empty_path) == Dict{String,String}[] # Header only, no data rows header_only = joinpath(dir, "header.csv") open(header_only, "w") do io write(io, "name,value\n") end - @test load_simple_csv(header_only) == Dict{String, String}[] + @test load_simple_csv(header_only) == Dict{String,String}[] # Rows with fewer columns than header short_row = joinpath(dir, "short.csv") @@ -368,13 +406,15 @@ end cons = Consequence(:breach, 0.9, "Data breach") model_with_mit = BowtieModel( - Hazard(:H, "Hazard"), TopEvent(:T, "Top"), + Hazard(:H, "Hazard"), + TopEvent(:T, "Top"), [ThreatPath(threat, Barrier[], EscalationFactor[])], [ConsequencePath(cons, [barrier_mit], EscalationFactor[])], ProbabilityModel(:independent), ) model_without_mit = BowtieModel( - Hazard(:H, "Hazard"), TopEvent(:T, "Top"), + Hazard(:H, "Hazard"), + TopEvent(:T, "Top"), [ThreatPath(threat, Barrier[], EscalationFactor[])], [ConsequencePath(cons, Barrier[], EscalationFactor[])], ProbabilityModel(:independent), @@ -387,7 +427,8 @@ end @test s_with.top_event_probability ≈ s_without.top_event_probability # Mitigative barrier should reduce consequence probability and risk - @test s_with.consequence_probabilities[:breach] < s_without.consequence_probabilities[:breach] + @test s_with.consequence_probabilities[:breach] < + s_without.consequence_probabilities[:breach] @test s_with.consequence_risks[:breach] < s_without.consequence_risks[:breach] # Without barrier: cons_prob = top_event_prob * 1.0 = 0.1 @@ -398,21 +439,44 @@ end @testset "Large model stress test" begin # 50 barriers across 20 threat paths, 10 consequence paths - threat_paths = [ThreatPath( - Threat(Symbol("t$i"), 0.01 * i, "Threat $i"), - [Barrier(Symbol("b$(i)_$(j)"), 0.5 + 0.05j, :preventive, "B", 0.0, :none) for j in 1:5], - EscalationFactor[], - ) for i in 1:10] - consequence_paths = [ConsequencePath( - Consequence(Symbol("c$i"), 0.1 * i, "Consequence $i"), - [Barrier(Symbol("mb$(i)_$(j)"), 0.6 + 0.05j, :mitigative, "MB", 0.0, :none) for j in 1:3], - EscalationFactor[], - ) for i in 1:5] + threat_paths = [ + ThreatPath( + Threat(Symbol("t$i"), 0.01 * i, "Threat $i"), + [ + Barrier( + Symbol("b$(i)_$(j)"), + 0.5 + 0.05j, + :preventive, + "B", + 0.0, + :none, + ) for j = 1:5 + ], + EscalationFactor[], + ) for i = 1:10 + ] + consequence_paths = [ + ConsequencePath( + Consequence(Symbol("c$i"), 0.1 * i, "Consequence $i"), + [ + Barrier( + Symbol("mb$(i)_$(j)"), + 0.6 + 0.05j, + :mitigative, + "MB", + 0.0, + :none, + ) for j = 1:3 + ], + EscalationFactor[], + ) for i = 1:5 + ] big_model = BowtieModel( Hazard(:BigHazard, "Large scenario"), TopEvent(:BigEvent, "Large top event"), - threat_paths, consequence_paths, + threat_paths, + consequence_paths, ProbabilityModel(:independent), ) @@ -448,13 +512,29 @@ end Threat(:EquipmentFailure, 0.05, "Pump seal failure"), [ Barrier(:PressureRelief, 0.8, :preventive, "Relief valve", 0.05, :none), - Barrier(:Inspection, 0.6, :preventive, "Regular inspection", 0.1, :shared_maint), + Barrier( + :Inspection, + 0.6, + :preventive, + "Regular inspection", + 0.1, + :shared_maint, + ), ], [EscalationFactor(:TrainingGap, 0.3, "Poor operator training")], ), ThreatPath( Threat(:ExternalIgnition, 0.02, "Hot work ignition"), - [Barrier(:HotWorkPermit, 0.7, :preventive, "Permit system", 0.0, :shared_maint)], + [ + Barrier( + :HotWorkPermit, + 0.7, + :preventive, + "Permit system", + 0.0, + :shared_maint, + ), + ], EscalationFactor[], ), ] @@ -477,7 +557,13 @@ end ] # 4. BUILD model with dependent mode - model_e2e = BowtieModel(hazard_e2e, top_event_e2e, threat_paths_e2e, consequence_paths_e2e, ProbabilityModel(:dependent)) + model_e2e = BowtieModel( + hazard_e2e, + top_event_e2e, + threat_paths_e2e, + consequence_paths_e2e, + ProbabilityModel(:dependent), + ) # 5. EVALUATE deterministically summary_e2e = evaluate(model_e2e) @@ -496,22 +582,26 @@ end :Inspection => BarrierDistribution(:triangular, (0.3, 0.6, 0.9)), :Evacuation => BarrierDistribution(:beta, (3.0, 2.0, 0.0)), ) - sim_e2e = simulate(model_e2e; samples=2000, barrier_dists=barrier_dists_e2e) + sim_e2e = simulate(model_e2e; samples = 2000, barrier_dists = barrier_dists_e2e) @test sim_e2e.top_event_mean > 0 @test length(sim_e2e.samples) == 2000 @test abs(sim_e2e.top_event_mean - summary_e2e.top_event_probability) < 0.3 # 7. SENSITIVITY analysis - tornado_e2e = sensitivity_tornado(model_e2e; delta=0.15) + tornado_e2e = sensitivity_tornado(model_e2e; delta = 0.15) @test length(tornado_e2e) > 0 # Should be sorted by impact (descending) impacts_e2e = [abs(t[3] - t[2]) for t in tornado_e2e] - @test issorted(impacts_e2e, rev=true) + @test issorted(impacts_e2e, rev = true) # 8. EXPORT all formats dir_e2e = mktempdir() write_model_json(joinpath(dir_e2e, "model.json"), model_e2e) - write_report_markdown(joinpath(dir_e2e, "report.md"), model_e2e; tornado_data=tornado_e2e) + write_report_markdown( + joinpath(dir_e2e, "report.md"), + model_e2e; + tornado_data = tornado_e2e, + ) write_tornado_csv(joinpath(dir_e2e, "tornado.csv"), tornado_e2e) write_schema_json(joinpath(dir_e2e, "schema.json")) @@ -539,7 +629,7 @@ end end # 11. REPORT content validation - report_e2e = report_markdown(model_e2e; tornado_data=tornado_e2e) + report_e2e = report_markdown(model_e2e; tornado_data = tornado_e2e) @test contains(report_e2e, "ChemicalFire") @test contains(report_e2e, "Sensitivity") end @@ -550,26 +640,53 @@ end @testset "Performance benchmarks" begin # Build a large model: 20 threat paths x 5 barriers, 10 consequence paths x 3 barriers - perf_threats = [ThreatPath( - Threat(Symbol("t$i"), 0.01 * i, "Threat $i"), - [Barrier(Symbol("b$(i)_$(j)"), 0.5 + 0.05j, :preventive, "B", 0.0, :none) for j in 1:5], - EscalationFactor[], - ) for i in 1:20] - perf_consequences = [ConsequencePath( - Consequence(Symbol("c$i"), 0.1 * i, "Consequence $i"), - [Barrier(Symbol("mb$(i)_$(j)"), 0.6 + 0.05j, :mitigative, "MB", 0.0, :none) for j in 1:3], - EscalationFactor[], - ) for i in 1:10] + perf_threats = [ + ThreatPath( + Threat(Symbol("t$i"), 0.01 * i, "Threat $i"), + [ + Barrier( + Symbol("b$(i)_$(j)"), + 0.5 + 0.05j, + :preventive, + "B", + 0.0, + :none, + ) for j = 1:5 + ], + EscalationFactor[], + ) for i = 1:20 + ] + perf_consequences = [ + ConsequencePath( + Consequence(Symbol("c$i"), 0.1 * i, "Consequence $i"), + [ + Barrier( + Symbol("mb$(i)_$(j)"), + 0.6 + 0.05j, + :mitigative, + "MB", + 0.0, + :none, + ) for j = 1:3 + ], + EscalationFactor[], + ) for i = 1:10 + ] big_model_perf = BowtieModel( Hazard(:BigHazard, "Large scenario"), TopEvent(:BigEvent, "Large top event"), - perf_threats, perf_consequences, + perf_threats, + perf_consequences, ProbabilityModel(:independent), ) # Warm up JIT evaluate(big_model_perf) - simulate(big_model_perf; samples=10, barrier_dists=Dict{Symbol, BarrierDistribution}()) + simulate( + big_model_perf; + samples = 10, + barrier_dists = Dict{Symbol,BarrierDistribution}(), + ) sensitivity_tornado(big_model_perf) # Evaluate should complete quickly @@ -577,7 +694,11 @@ end @test t_eval < 1.0 # Simulate should complete in reasonable time - t_sim = @elapsed simulate(big_model_perf; samples=5000, barrier_dists=Dict{Symbol, BarrierDistribution}()) + t_sim = @elapsed simulate( + big_model_perf; + samples = 5000, + barrier_dists = Dict{Symbol,BarrierDistribution}(), + ) @test t_sim < 30.0 # Sensitivity should complete From a5e80a1c834c6fc5e13055d53f483e2b121242c9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:16:33 +0100 Subject: [PATCH 10/12] ci: replace Python diagnostics and formatter helpers with bash (estate bans Python) (#61) The governance gate bans Python in the repository. The temporary diagnostic and commit helpers are rewritten in bash. Also adds the SPDX header and job-level permissions to the temporary format workflow. --- .github/scripts/emit-job-diagnostics.py | 11 ------- .github/scripts/emit-job-diagnostics.sh | 22 +++++++++++++ .github/scripts/tmp-commit-formatted.py | 41 ------------------------- .github/scripts/tmp-commit-formatted.sh | 36 ++++++++++++++++++++++ .github/workflows/julia-ci.yml | 10 +++--- 5 files changed, 63 insertions(+), 57 deletions(-) delete mode 100644 .github/scripts/emit-job-diagnostics.py create mode 100644 .github/scripts/emit-job-diagnostics.sh delete mode 100644 .github/scripts/tmp-commit-formatted.py create mode 100644 .github/scripts/tmp-commit-formatted.sh diff --git a/.github/scripts/emit-job-diagnostics.py b/.github/scripts/emit-job-diagnostics.py deleted file mode 100644 index 05f108e..0000000 --- a/.github/scripts/emit-job-diagnostics.py +++ /dev/null @@ -1,11 +0,0 @@ -# TEMPORARY diagnostic (#61): publish the tail of a failing step's output as -# check annotations, because raw job logs are not retrievable outside the UI. -# Removed once the root cause of the Julia install/format/JET failures is fixed. -import pathlib, sys -path = pathlib.Path(sys.argv[1]) -text = path.read_text(errors="replace") if path.exists() else "(no log captured)" -text = text[-12000:] -def esc(s): - return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") -for i in range(0, len(text), 3000): - print(f"::error title=diag {i // 3000}::{esc(text[i:i + 3000])}") diff --git a/.github/scripts/emit-job-diagnostics.sh b/.github/scripts/emit-job-diagnostics.sh new file mode 100644 index 0000000..7b5499e --- /dev/null +++ b/.github/scripts/emit-job-diagnostics.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# TEMPORARY (#61): publish the tail of a failing step's output as check +# annotations, since raw job logs are not retrievable outside the UI. +# Remove together with the `diag` steps in julia-ci.yml. +set -uo pipefail +f="${1:-}" +if [ -z "$f" ] || [ ! -s "$f" ]; then + echo "::error title=diag::(no log captured: ${f:-unset})" + exit 0 +fi +# Last 12 KB of output, in 3 KB pieces. Escape % and newlines per the +# workflow-command rules so each piece is one annotation. +tail -c 12000 "$f" | split -b 3000 - /tmp/diag-piece. +n=0 +for piece in /tmp/diag-piece.*; do + esc=$(sed -e 's/%/%25/g' -e 's/\r/%0D/g' "$piece" | awk 'BEGIN{ORS=""} NR>1{print "%0A"} {print}') + echo "::error title=diag ${n}::${esc}" + n=$((n + 1)) +done +rm -f /tmp/diag-piece.* +exit 0 diff --git a/.github/scripts/tmp-commit-formatted.py b/.github/scripts/tmp-commit-formatted.py deleted file mode 100644 index 2bd4248..0000000 --- a/.github/scripts/tmp-commit-formatted.py +++ /dev/null @@ -1,41 +0,0 @@ -# TEMPORARY (#61): commit whatever the formatter changed through the GraphQL -# createCommitOnBranch mutation, so GitHub signs the commit. -import base64 -import json -import os -import subprocess -import sys - -repo = os.environ["GITHUB_REPOSITORY"] -branch = os.environ["GITHUB_REF_NAME"] -head = os.environ["GITHUB_SHA"] - -files = subprocess.check_output(["git", "diff", "--name-only"]).decode().split() -if not files: - print("nothing to format") - sys.exit(0) - -additions = [ - {"path": f, "contents": base64.b64encode(open(f, "rb").read()).decode()} - for f in files -] -query = ( - "mutation($input: CreateCommitOnBranchInput!) {" - " createCommitOnBranch(input: $input) { commit { oid } } }" -) -variables = { - "input": { - "branch": {"repositoryNameWithOwner": repo, "branchName": branch}, - "message": { - "headline": "style(format): apply JuliaFormatter to src and test (#61)", - "body": "Automated JuliaFormatter pass (DefaultStyle) over src/ and test/, " - "committed through the GitHub API so GitHub signs it.", - }, - "fileChanges": {"additions": additions, "deletions": []}, - "expectedHeadOid": head, - } -} -with open("/tmp/format-req.json", "w") as fh: - json.dump({"query": query, "variables": variables}, fh) -subprocess.run(["gh", "api", "graphql", "--input", "/tmp/format-req.json"], check=True) -print("committed formatted files:", files) diff --git a/.github/scripts/tmp-commit-formatted.sh b/.github/scripts/tmp-commit-formatted.sh new file mode 100644 index 0000000..d173dd3 --- /dev/null +++ b/.github/scripts/tmp-commit-formatted.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# TEMPORARY (#61): commit whatever the formatter changed through the GraphQL +# createCommitOnBranch mutation, so GitHub signs the commit. +set -euo pipefail + +files=$(git diff --name-only) +if [ -z "$files" ]; then + echo "nothing to format" + exit 0 +fi + +adds=$(for f in $files; do + jq -n --arg p "$f" --rawfile c "$f" '{path: $p, contents: ($c | @base64)}' +done | jq -s '.') + +jq -n \ + --arg repo "$GITHUB_REPOSITORY" \ + --arg branch "$GITHUB_REF_NAME" \ + --arg head "$GITHUB_SHA" \ + --argjson adds "$adds" \ + '{ + query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", + variables: {input: { + branch: {repositoryNameWithOwner: $repo, branchName: $branch}, + message: { + headline: "style(format): apply JuliaFormatter to src and test (#61)", + body: "Automated JuliaFormatter pass (DefaultStyle) over src/ and test/, committed through the GitHub API so GitHub signs it." + }, + fileChanges: {additions: $adds, deletions: []}, + expectedHeadOid: $head + }} + }' > /tmp/format-req.json + +gh api graphql --input /tmp/format-req.json +echo "committed formatted files: $files" diff --git a/.github/workflows/julia-ci.yml b/.github/workflows/julia-ci.yml index 21c5f20..434b980 100644 --- a/.github/workflows/julia-ci.yml +++ b/.github/workflows/julia-ci.yml @@ -12,7 +12,7 @@ # No `continue-on-error` anywhere: a red job is the gate working. # # TEMPORARY: the `diag` steps publish failing output as annotations (see -# .github/scripts/emit-job-diagnostics.py). Remove with that script. +# .github/scripts/emit-job-diagnostics.sh). Remove with that script. name: Julia CI on: @@ -75,7 +75,7 @@ jobs: exit $rc - name: diag if: failure() - run: python3 .github/scripts/emit-job-diagnostics.py fmt.log + run: bash .github/scripts/emit-job-diagnostics.sh fmt.log jet: name: Static analysis (JET) @@ -107,7 +107,7 @@ jobs: exit $rc - name: diag if: failure() - run: python3 .github/scripts/emit-job-diagnostics.py jet.log + run: bash .github/scripts/emit-job-diagnostics.sh jet.log test: name: Test (Test + Aqua) @@ -134,7 +134,7 @@ jobs: exit $rc - name: diag if: failure() - run: python3 .github/scripts/emit-job-diagnostics.py install.log + run: bash .github/scripts/emit-job-diagnostics.sh install.log - name: Pkg.test() run: | set +e @@ -144,4 +144,4 @@ jobs: exit $rc - name: diag if: failure() - run: python3 .github/scripts/emit-job-diagnostics.py test.log + run: bash .github/scripts/emit-job-diagnostics.sh test.log From 2a3dbe5399f267075fc34e090927f10322ca584e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:18:38 +0100 Subject: [PATCH 11/12] fix(test): declare Random as a test extra; fix the temporary format workflow (#61) Pkg.test only exposes project deps and extras, so 'using Random' in test/runtests.jl failed with 'Package Random not found'. Random is now a declared test extra with a compat bound (rsr-criteria 5.2.3). Also restores the SPDX header and job-level permissions of the temporary format workflow, which had been reverted, and widens the temporary diagnostics window. --- .github/scripts/emit-job-diagnostics.sh | 4 ++-- .github/workflows/tmp-format-apply.yml | 9 +++++++-- Project.toml | 4 +++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/scripts/emit-job-diagnostics.sh b/.github/scripts/emit-job-diagnostics.sh index 7b5499e..a3fb290 100644 --- a/.github/scripts/emit-job-diagnostics.sh +++ b/.github/scripts/emit-job-diagnostics.sh @@ -9,9 +9,9 @@ if [ -z "$f" ] || [ ! -s "$f" ]; then echo "::error title=diag::(no log captured: ${f:-unset})" exit 0 fi -# Last 12 KB of output, in 3 KB pieces. Escape % and newlines per the +# Last 40 KB of output, in 3 KB pieces. Escape % and newlines per the # workflow-command rules so each piece is one annotation. -tail -c 12000 "$f" | split -b 3000 - /tmp/diag-piece. +tail -c 40000 "$f" | split -b 3000 - /tmp/diag-piece. n=0 for piece in /tmp/diag-piece.*; do esc=$(sed -e 's/%/%25/g' -e 's/\r/%0D/g' "$piece" | awk 'BEGIN{ORS=""} NR>1{print "%0A"} {print}') diff --git a/.github/workflows/tmp-format-apply.yml b/.github/workflows/tmp-format-apply.yml index d26d362..6990dc0 100644 --- a/.github/workflows/tmp-format-apply.yml +++ b/.github/workflows/tmp-format-apply.yml @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: MPL-2.0 # TEMPORARY (#61): apply JuliaFormatter to src/ and test/ on this branch and # commit the result through the GitHub API, so GitHub signs it. Removed once # the formatted sources are on the branch. No-op when nothing changes. @@ -8,7 +9,7 @@ on: branches: [arena/ebd419f3-bowtierisk-jl] permissions: - contents: write + contents: read concurrency: group: tmp-format-apply-${{ github.ref }} @@ -18,6 +19,8 @@ jobs: apply: runs-on: ubuntu-24.04 timeout-minutes: 20 + permissions: + contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -30,5 +33,7 @@ jobs: - name: Commit formatted files through the API (GitHub-signed) env: GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_SHA: ${{ github.sha }} - run: python3 .github/scripts/tmp-commit-formatted.py + run: bash .github/scripts/tmp-commit-formatted.sh diff --git a/Project.toml b/Project.toml index dfbdbbf..27d7914 100644 --- a/Project.toml +++ b/Project.toml @@ -26,6 +26,7 @@ AcceleratorGate = "0.1" julia = "1.10" Test = "1" Aqua = "0.8" +Random = "1" LinearAlgebra = "1" JSON3 = "1" Distributions = "0.25" @@ -36,7 +37,8 @@ KernelAbstractions = "0.9" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Test", "Aqua"] +test = ["Test", "Aqua", "Random"] From d67625ff6e2980622d43f63c2d5508906f25f8d2 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:28:06 +0000 Subject: [PATCH 12/12] fix(model): materialize JSON values and validate probability modes before evaluation --- src/BowtieRisk.jl | 17 +++++++++----- test/e2e_test.jl | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/BowtieRisk.jl b/src/BowtieRisk.jl index 1ed89f9..e8f8a6a 100644 --- a/src/BowtieRisk.jl +++ b/src/BowtieRisk.jl @@ -315,6 +315,10 @@ end Compute residual probabilities, top event probability, and consequence risk. """ function evaluate(model::BowtieModel) + if !(model.probability_model.mode in (:independent, :dependent)) + error("unknown probability model mode: $(model.probability_model.mode)") + end + threat_residuals = Dict{Symbol,Float64}() residual_values = Float64[] @@ -955,11 +959,12 @@ function write_model_json(path::AbstractString, model::BowtieModel) nothing end -# Typed accessors for JSON3 values. JSON3 yields a Union of JSON types, so each -# value is checked once here; a malformed model file raises ArgumentError rather +# Typed accessors for materialized JSON values. Concrete dictionaries avoid +# indexing JSON3's abstract lazy buffer/tape types during static analysis. +# Each value is checked here so malformed fields raise ArgumentError rather # than a MethodError deep inside the constructors. -_json_obj(x)::JSON3.Object = - x isa JSON3.Object ? x : +_json_obj(x)::Dict{String,Any} = + x isa Dict{String,Any} ? x : throw(ArgumentError("model JSON: expected an object, got $(typeof(x))")) _json_arr(x)::AbstractVector = x isa AbstractVector ? x : @@ -971,7 +976,7 @@ _json_num(x)::Float64 = x isa Real ? Float64(x) : throw(ArgumentError("model JSON: expected a number, got $(typeof(x))")) _json_sym(x)::Symbol = Symbol(_json_str(x)) -_json_field(o, key::String) = _json_obj(o)[Symbol(key)] +_json_field(o, key::String) = _json_obj(o)[key] function _read_barrier(b) Barrier( @@ -1002,7 +1007,7 @@ end Read a bowtie model from JSON produced by write_model_json. """ function read_model_json(path::AbstractString) - obj = _json_obj(JSON3.read(read(path, String))) + obj = _json_obj(JSON3.read(read(path, String), Any)) hz = _json_obj(_json_field(obj, "hazard")) te = _json_obj(_json_field(obj, "top_event")) hazard = Hazard( diff --git a/test/e2e_test.jl b/test/e2e_test.jl index 6d2c4b5..4218296 100644 --- a/test/e2e_test.jl +++ b/test/e2e_test.jl @@ -5,6 +5,7 @@ using Test using BowtieRisk +using JSON3 @testset "E2E Pipeline Tests" begin @@ -133,6 +134,27 @@ using BowtieRisk ) @test_throws ErrorException evaluate(bad_model) + # Mode validation also applies when there are no paths to evaluate. + empty_model = BowtieModel( + bad_model.hazard, + bad_model.top_event, + ThreatPath[], + ConsequencePath[], + bad_model.probability_model, + ) + @test_throws ErrorException evaluate(empty_model) + + for mode in (:independent, :dependent) + valid_model = BowtieModel( + bad_model.hazard, + bad_model.top_event, + bad_model.threat_paths, + bad_model.consequence_paths, + ProbabilityModel(mode), + ) + @test evaluate(valid_model).top_event_probability ≈ 0.1 + end + # Unknown barrier distribution in simulation simple_model = BowtieModel( Hazard(:H, "Hazard"), @@ -165,6 +187,10 @@ using BowtieRisk model2 = read_model_json(path) summary2 = evaluate(model2) + roundtrip_path = joinpath(dir, "roundtrip.json") + write_model_json(roundtrip_path, model2) + @test JSON3.read(read(roundtrip_path, String)) == JSON3.read(read(path, String)) + @test summary2.top_event_probability ≈ summary.top_event_probability for (k, v) in summary.threat_residuals @test summary2.threat_residuals[k] ≈ v @@ -174,4 +200,34 @@ using BowtieRisk end end + @testset "Malformed JSON field types" begin + mktempdir() do dir + path = joinpath(dir, "model.json") + write_model_json(path, template_model(:process_safety)) + original = read(path, String) + + for (keys, value) in ( + (("hazard",), nothing), + (("hazard", "name"), 42), + (("top_event", "description"), []), + (("threat_paths",), nothing), + (("threat_paths", 1, "threat", "probability"), "0.1"), + (("threat_paths", 1, "barriers", 1), false), + (("threat_paths", 1, "escalation_factors"), "invalid"), + ) + obj = JSON3.read(original, Dict{String,Any}) + parent = obj + for key in keys[1:(end-1)] + parent = parent[key] + end + parent[last(keys)] = value + write(path, JSON3.write(obj)) + @test_throws ArgumentError read_model_json(path) + end + + write(path, "[]") + @test_throws ArgumentError read_model_json(path) + end + end + end