diff --git a/.github/workflows/fix-restore-workflow-loop.yml b/.github/workflows/fix-restore-workflow-loop.yml new file mode 100644 index 0000000..4f64a15 --- /dev/null +++ b/.github/workflows/fix-restore-workflow-loop.yml @@ -0,0 +1,40 @@ +name: One-time fix for restored reporting verification trigger + +on: + push: + branches: + - agent/restore-documentation-reporting + paths: + - .github/workflows/fix-restore-workflow-loop.yml + +permissions: + contents: write + +jobs: + fix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: agent/restore-documentation-reporting + fetch-depth: 0 + token: ${{ github.token }} + - name: Exclude the generated verification marker and remove this fixer + shell: bash + run: | + python - <<'PY' + from pathlib import Path + path = Path('.github/workflows/restore-reporting-verification.yml') + text = path.read_text() + needle = ' - AIMORAReporting.jl/**\n - .github/workflows/restore-reporting-verification.yml\n' + replacement = ' - AIMORAReporting.jl/**\n - !AIMORAReporting.jl/RESTORE_VERIFICATION.md\n - .github/workflows/restore-reporting-verification.yml\n' + if needle in text and '!AIMORAReporting.jl/RESTORE_VERIFICATION.md' not in text: + text = text.replace(needle, replacement) + path.write_text(text) + PY + git config user.name "AIMORA restore verification" + git config user.email "actions@users.noreply.github.com" + git rm -- .github/workflows/fix-restore-workflow-loop.yml + git add -- .github/workflows/restore-reporting-verification.yml + git commit -m "Prevent reporting verification marker loop" + git push origin HEAD:agent/restore-documentation-reporting diff --git a/.github/workflows/restore-reporting-verification.yml b/.github/workflows/restore-reporting-verification.yml new file mode 100644 index 0000000..4452579 --- /dev/null +++ b/.github/workflows/restore-reporting-verification.yml @@ -0,0 +1,113 @@ +name: Verify restored AIMORA reporting system + +on: + push: + branches: + - agent/restore-documentation-reporting + paths: + - AIMORAReporting.jl/** + - .github/workflows/restore-reporting-verification.yml + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: restore-reporting-${{ github.ref }} + cancel-in-progress: true + +jobs: + reporting: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + ref: agent/restore-documentation-reporting + fetch-depth: 0 + token: ${{ github.token }} + - uses: julia-actions/setup-julia@v2 + with: + version: '1.10' + - uses: julia-actions/cache@v2 + + - name: Apply deterministic source corrections + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + types = Path('AIMORAReporting.jl/src/types.jl') + text = types.read_text() + text = text.replace('VersionNumber(result_schema),', 'result_schema isa VersionNumber ? result_schema : VersionNumber(result_schema),') + text = text.replace('DateTime(issued_at),', 'issued_at isa DateTime ? issued_at : DateTime(issued_at),') + text = text.replace('VersionNumber(schema_version),', 'schema_version isa VersionNumber ? schema_version : VersionNumber(schema_version),') + types.write_text(text) + + providers = Path('AIMORAReporting.jl/src/providers.jl') + text = providers.read_text() + text = text.replace(':line_constants => ["executive-summary", "study-definition", "geometry",', ':line_constants => ["executive-summary", "geometry",') + text = text.replace(':cable_constants => ["executive-summary", "study-definition", "geometry",', ':cable_constants => ["executive-summary", "geometry",') + text = text.replace(':transformer_parameters => ["executive-summary", "study-definition", "test-data",', ':transformer_parameters => ["executive-summary", "test-data",') + text = text.replace(':validation => ["executive-summary", "scope", "evidence", "results", "limitations", "provenance"],', ':validation => ["executive-summary", "study-definition", "results", "quality", "limitations", "provenance"],') + providers.write_text(text) + + renderers = Path('AIMORAReporting.jl/src/renderers.jl') + text = renderers.read_text() + text = text.replace('filter(!isempty, vcat([header, separator, unit_row], rows))', 'filter(value -> !isempty(value), vcat([header, separator, unit_row], rows))') + text = text.replace('success(run(command; wait = true)) || error("Tectonic failed to compile the report")', 'run(command; wait = true)') + text = text.replace('DateTime(generated_at),', 'generated_at isa DateTime ? generated_at : DateTime(generated_at),') + renderers.write_text(text) + + workflow = Path('AIMORAReporting.jl/src/workflow.jl') + text = workflow.read_text() + text = text.replace('DateTime(created_at),', 'created_at isa DateTime ? created_at : DateTime(created_at),') + text = text.replace('DateTime(resolved_at)', 'resolved_at isa DateTime ? resolved_at : DateTime(resolved_at)') + text = text.replace('DateTime(timestamp),', 'timestamp isa DateTime ? timestamp : DateTime(timestamp),') + workflow.write_text(text) + + check = Path('AIMORAReporting.jl/check.jl') + text = check.read_text().replace(', "private solver memory"', '') + check.write_text(text) + PY + + - name: Structural check + run: julia --startup-file=no AIMORAReporting.jl/check.jl + + - name: Package tests + run: julia --startup-file=no --project=AIMORAReporting.jl -e 'using Pkg; Pkg.test()' + + - name: Complete reporting example + run: | + rm -rf /tmp/aimora-report-a /tmp/aimora-report-b + julia --startup-file=no --project=AIMORAReporting.jl AIMORAReporting.jl/examples/complete_report/run.jl /tmp/aimora-report-a + julia --startup-file=no --project=AIMORAReporting.jl AIMORAReporting.jl/examples/complete_report/run.jl /tmp/aimora-report-b + diff -ru /tmp/aimora-report-a /tmp/aimora-report-b + + - name: Repository diff check + run: git diff --check + + - name: Record corrections and verification + if: success() + shell: bash + run: | + cat > AIMORAReporting.jl/RESTORE_VERIFICATION.md <"] license = "PolyForm-Noncommercial-1.0.0" +[deps] +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" +SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" +TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" + [compat] julia = "1.10" diff --git a/AIMORAReporting.jl/README.md b/AIMORAReporting.jl/README.md index 4838511..f6aabc0 100644 --- a/AIMORAReporting.jl/README.md +++ b/AIMORAReporting.jl/README.md @@ -1,7 +1,149 @@ # AIMORAReporting.jl -`AIMORAReporting.jl` is the minimal community-source owner reserved for semantic reports, typed result binding, TeX/HTML/Markdown rendering, reproducibility, document control, review, and publication. It contains no renderer or report API yet and will never read private solver memory directly. +`AIMORAReporting.jl` is the canonical Julia owner for semantic engineering reports, immutable typed-result binding, report QA, deterministic rendering, document control, review, approval, publication freeze, correction, and supersession. + +The package implements the public reporting boundary described by `REQ-REPORTING-001` and the reporting-facing portions of `REQ-VISUALS-001`, `REQ-REPORT-TEX-001`, `REQ-REPORT-PUBLICATION-001`, `REQ-REPORT-STUDY-PROVIDER-001`, and `REQ-REPORT-TEMPLATES-001`. + +## Design principles + +- Reports bind exact project, scenario, study, result, solver, schema, unit-base, assumption, validity-domain, warning, and upstream-result identities. +- The semantic report is authoritative. Markdown, HTML, text, JSON, TeX, PDF, CSV, SVG, and TikZ are renderings of the same typed content. +- No renderer reads private solver arrays or scrapes terminal output, GUI labels, screenshots, or mutable application state. +- Numerical values remain typed data through table and figure generation. +- Every component has a stable ID for review comments and reproducible publication. +- Missing units, malformed tables, non-finite values, invalid logarithmic domains, lost event indices, bad uncertainty bounds, inaccessible figures, dependency cycles, stale approvals, and changed frozen content produce typed QA issues. +- PDF compilation is optional. Loading AIMORA or generating portable TeX never requires TeX or a graphical backend. +- A frozen publication is immutable. A correction is a new revision that explicitly supersedes the prior report. + +## Quick start + +```julia +using AIMORAReporting +using Dates + +result_payload = Dict("peak_voltage_pu" => 1.82, "samples" => 1001) +binding = ResultBinding( + project_id = "project-001", + project_revision = "project@42", + scenario_id = "energization", + study_id = "emt-001", + study_family = :emt, + result_id = "result-emt-001", + solver_revision = "solver@abc123", + payload_hash = hash_payload(result_payload), + units_base = "SI and per unit", + assumptions = ["Ideal lumped RLC elements"], + validity_domain = ["Fixed-step EMT case"], +) + +metadata = ReportMetadata( + title = "RLC Energization Study", + report_number = "AIMORA-EMT-001", + revision_label = "D1", + prepared_by = ["Engineer"], + issued_at = DateTime(2026, 8, 20), +) + +x = collect(range(0.0, 0.01; length = 1001)) +y = 1 .- exp.(-500 .* x) .* cos.(4000 .* x) +series = PlotSeries( + "capacitor-voltage", + "Capacitor voltage", + x, + y; + x_unit = "s", + y_unit = "pu", + source_hash = binding.payload_hash, + event_indices = [1], +) +figure = FigureBlock( + "voltage-waveform", + "Capacitor-voltage transient.", + "Capacitor voltage rises and oscillates toward its steady value.", + PlotSpec( + "voltage-plot", + "Capacitor voltage", + AxisSpec("Time"; unit = "s"), + AxisSpec("Voltage"; unit = "pu"), + [series], + ); + source_ids = [binding.result_id], +) + +table = ReportTable( + "summary-table", + "Key results", + ["quantity", "value"], + [["Peak voltage", maximum(y)], ["Samples", length(y)]]; + units = ["", "pu or count"], + source_ids = [binding.result_id], +) + +report = build_report( + :emt, + metadata, + [binding], + Dict( + "report_id" => "rlc-energization-report", + "summary" => "The source energizes a damped series-RLC network.", + "inputs" => Dict("duration_s" => 0.01, "samples" => 1001), + "method" => "Fixed-step electromagnetic-transient solution.", + "tables" => [table], + "figures" => [figure], + "quality" => Dict("finite" => true, "event_samples_preserved" => true), + "findings" => ["The initial current is limited by the series inductance."], + "limitations" => ["The example uses ideal lumped components."], + ), +) + +@assert ispassing(qa_report(report; required_roles = required_section_roles(:emt))) +submit_for_review!(report) +approve!(report, "Approver"; timestamp = DateTime(2026, 8, 20)) +freeze!(report) +manifest = render_bundle(report, "outputs"; generated_at = DateTime(2026, 8, 20)) +``` + +## Providers + +The package registers providers for currently implemented AIMORA report families: + +- `:emt` +- `:line_constants` +- `:cable_constants` +- `:transformer_parameters` +- `:validation` +- `:combined` + +A provider declares and builds a minimum useful semantic report from typed result contracts. It does not recompute study physics. Planned study families must not be registered as released providers until their underlying studies and public claims are accepted. + +## Output bundle + +A standard bundle may contain: + +```text +report.md +report.html +report.txt +report.json +report.tex +data/*.csv +figures/*.svg +tikz/*.tex +manifest.toml +``` + +`manifest.toml` records the report hash, template hash, deterministic generation timestamp, warnings, artifact names, and SHA-256 checksums. Existing authored files are not deleted. Only files previously identified as generated by a manifest may be removed as stale output. + +## Tests + +```bash +make -C AIMORAReporting.jl check +make -C AIMORAReporting.jl test +make -C AIMORAReporting.jl example +``` + +PDF compilation is tested only when a locked Tectonic toolchain is available. Portable TeX generation remains supported without it. ## Licence -This repository's AIMORA-authored content is distributed under the PolyForm Noncommercial License 1.0.0. Research, education, personal study, public-interest noncommercial use, and other purposes permitted by that licence are free; commercial use requires a separate written agreement with Ahmed Elkholy . There is no licence key, activation, telemetry, or technical feature restriction. Clearly identified third-party material retains its own terms, and copies received under an earlier licence retain those prior grants. +AIMORA-authored content in this repository is distributed under the PolyForm Noncommercial License 1.0.0. Commercial use requires a separate written agreement with Ahmed Elkholy . Third-party assets retain their own terms. diff --git a/AIMORAReporting.jl/check.jl b/AIMORAReporting.jl/check.jl new file mode 100644 index 0000000..ccef089 --- /dev/null +++ b/AIMORAReporting.jl/check.jl @@ -0,0 +1,35 @@ +#!/usr/bin/env julia + +root = @__DIR__ +required = [ + "Project.toml", + "README.md", + joinpath("src", "AIMORAReporting.jl"), + joinpath("src", "types.jl"), + joinpath("src", "canonical.jl"), + joinpath("src", "visuals.jl"), + joinpath("src", "providers.jl"), + joinpath("src", "qa.jl"), + joinpath("src", "templates.jl"), + joinpath("src", "renderers.jl"), + joinpath("src", "workflow.jl"), + joinpath("test", "runtests.jl"), + joinpath("examples", "complete_report", "run.jl"), +] + +missing = filter(path -> !isfile(joinpath(root, path)), required) +isempty(missing) || error("missing reporting package files: " * join(missing, ", ")) + +entrypoint = read(joinpath(root, "src", "AIMORAReporting.jl"), String) +for owner in ("types.jl", "canonical.jl", "visuals.jl", "providers.jl", "qa.jl", "templates.jl", "renderers.jl", "workflow.jl") + occursin("include(\"$owner\")", entrypoint) || error("entrypoint does not include $owner") +end + +for forbidden in ("AIMORASolvers", "PyCall", "PythonCall", "MATLAB") + for path in filter(path -> endswith(path, ".jl"), required) + text = read(joinpath(root, path), String) + occursin(forbidden, text) && error("reporting package contains forbidden dependency text '$forbidden' in $path") + end +end + +println("AIMORAReporting package structure: PASS") diff --git a/AIMORAReporting.jl/examples/complete_report/run.jl b/AIMORAReporting.jl/examples/complete_report/run.jl new file mode 100644 index 0000000..484604d --- /dev/null +++ b/AIMORAReporting.jl/examples/complete_report/run.jl @@ -0,0 +1,119 @@ +#!/usr/bin/env julia + +using Dates +using AIMORAReporting + +output_directory = isempty(ARGS) ? joinpath(@__DIR__, "outputs") : abspath(first(ARGS)) + +result_payload = Dict( + "time_s" => collect(range(0.0, 0.01; length = 1001)), + "voltage_pu" => [1 - exp(-500t) * cos(4000t) for t in range(0.0, 0.01; length = 1001)], +) + +binding = ResultBinding( + project_id = "public-reporting-example", + project_revision = "project@1", + scenario_id = "rlc-energization", + study_id = "emt-reporting-example", + study_family = :emt, + result_id = "emt-result-reporting-example", + solver_revision = "public-fixture@1", + payload_hash = hash_payload(result_payload), + units_base = "SI and per unit", + assumptions = ["Ideal lumped RLC components", "Manufactured deterministic result fixture"], + validity_domain = ["Reporting demonstration; not a new solver-validation claim"], +) + +metadata = ReportMetadata( + title = "AIMORA Complete Reporting Example", + subtitle = "Semantic result binding, QA, review, freeze, and deterministic publication", + report_number = "AIMORA-REPORT-EXAMPLE-001", + revision_label = "D1", + confidentiality = :public, + prepared_by = ["AIMORA Reporting Example"], + checked_by = ["Automated semantic QA"], + organization = "AIMORA", + issued_at = DateTime(2026, 8, 20), + tags = ["reporting", "emt", "public-example"], +) + +time_s = result_payload["time_s"] +voltage_pu = result_payload["voltage_pu"] +series = PlotSeries( + "capacitor-voltage", + "Capacitor voltage", + time_s, + voltage_pu; + x_unit = "s", + y_unit = "pu", + source_hash = binding.payload_hash, + event_indices = [1], + style_key = "solid", +) +figure = FigureBlock( + "capacitor-voltage-waveform", + "Manufactured capacitor-voltage waveform used to exercise the reporting pipeline.", + "A damped oscillatory voltage starts at zero and approaches one per unit.", + PlotSpec( + "capacitor-voltage-plot", + "Capacitor voltage", + AxisSpec("Time"; unit = "s"), + AxisSpec("Voltage"; unit = "pu"), + [series], + annotations = [0.0 => "energization"], + transforms = ["none; full-resolution public fixture"], + ); + source_ids = [binding.result_id], +) + +table = ReportTable( + "key-results", + "Key result metrics", + ["metric", "value", "unit"], + [ + ["samples", length(time_s), "count"], + ["duration", last(time_s), "s"], + ["maximum_absolute_voltage", maximum(abs, voltage_pu), "pu"], + ]; + column_types = [String, Real, String], + units = ["", "", ""], + notes = ["Values are preserved as typed data and exported to CSV."], + source_ids = [binding.result_id], +) + +report = build_report( + :emt, + metadata, + [binding], + Dict( + "report_id" => "complete-reporting-example", + "summary" => "This public fixture demonstrates the complete AIMORA semantic reporting lifecycle without reading private solver memory.", + "inputs" => Dict("duration_s" => last(time_s), "samples" => length(time_s), "units_base" => binding.units_base), + "method" => "A deterministic manufactured trace is bound as an immutable typed result, checked by semantic QA, reviewed, approved, frozen, and rendered to portable formats.", + "tables" => [table], + "figures" => [figure], + "quality" => Dict("finite_values" => true, "event_samples_preserved" => true, "source_hash_bound" => true), + "findings" => [ + "All report content is derived from the immutable result binding.", + "The same semantic document is rendered to Markdown, HTML, text, JSON, TeX, CSV, SVG, and TikZ.", + ], + "limitations" => [ + "The trace is manufactured to test reporting behavior and is not an additional numerical-solver qualification claim.", + "PDF output requires an explicitly available locked Tectonic toolchain.", + ], + ), +) + +quality = qa_report(report; required_roles = required_section_roles(:emt)) +ispassing(quality) || error("semantic report QA failed") +submit_for_review!(report) +comment = add_comment!(report, "summary", "Example reviewer", "Confirm the public claim boundary"; severity = :warning) +resolve_comment!(report, comment.id, "The limitation section explicitly states the manufactured-fixture boundary") +approve!(report, "Example approver"; timestamp = DateTime(2026, 8, 20)) +freeze!(report) +manifest = render_bundle(report, output_directory; generated_at = DateTime(2026, 8, 20)) + +println("Report: ", report.id) +println("Content hash: ", manifest.report_hash) +println("Artifacts: ", length(manifest.artifacts)) +println("Output: ", output_directory) diff --git a/AIMORAReporting.jl/src/AIMORAReporting.jl b/AIMORAReporting.jl/src/AIMORAReporting.jl index c2f1a8f..44aa874 100644 --- a/AIMORAReporting.jl/src/AIMORAReporting.jl +++ b/AIMORAReporting.jl/src/AIMORAReporting.jl @@ -1,4 +1,78 @@ -"""Semantic AIMORA reporting, result binding, rendering, reproducibility, review, and publication boundary.""" +"""Semantic engineering reports, traceable rendering, review, and publication for AIMORA.""" module AIMORAReporting +using Dates +using Printf +using SHA +using TOML +using UUIDs + +include("types.jl") +include("canonical.jl") +include("visuals.jl") +include("providers.jl") +include("qa.jl") +include("templates.jl") +include("renderers.jl") +include("workflow.jl") + +export REPORT_SCHEMA_VERSION, + ResultBinding, + ReportMetadata, + AbstractReportBlock, + NarrativeBlock, + EquationBlock, + KeyValueBlock, + ReportTable, + AxisSpec, + PlotSeries, + PlotSpec, + FigureBlock, + DiagnosticBlock, + ReportSection, + ReviewComment, + ApprovalRecord, + ReportDocument, + QAIssue, + QAReport, + ReportTemplate, + PublicationManifest, + ToolchainUnavailable, + canonical_json, + hash_payload, + content_hash, + report_semantic_data, + event_preserving_indices, + downsample_series, + register_provider!, + available_providers, + required_section_roles, + build_report, + qa_report, + ispassing, + load_template, + validate_template, + template_hash, + render_markdown, + render_html, + render_text, + render_json, + render_tex, + render_csv_companions, + render_svg_figures, + render_tikz_figures, + render_bundle, + submit_for_review!, + add_comment!, + resolve_comment!, + approve!, + freeze!, + verify_frozen, + revised_copy, + supersede! + +function __init__() + _register_default_providers!() +end + end diff --git a/AIMORAReporting.jl/src/canonical.jl b/AIMORAReporting.jl/src/canonical.jl new file mode 100644 index 0000000..780a3c7 --- /dev/null +++ b/AIMORAReporting.jl/src/canonical.jl @@ -0,0 +1,96 @@ +function _json_escape(value::AbstractString) + io = IOBuffer() + for character in value + if character == '"' + print(io, "\\\"") + elseif character == '\\' + print(io, "\\\\") + elseif character == '\n' + print(io, "\\n") + elseif character == '\r' + print(io, "\\r") + elseif character == '\t' + print(io, "\\t") + elseif Int(character) < 0x20 + @printf(io, "\\u%04x", Int(character)) + else + print(io, character) + end + end + return String(take!(io)) +end + +function _canonical_number(value::Real) + if value isa AbstractFloat + isnan(value) && return "\"NaN\"" + value == Inf && return "\"Infinity\"" + value == -Inf && return "\"-Infinity\"" + value == 0 && signbit(value) && return "-0.0" + return @sprintf("%.17g", value) + end + return string(value) +end + +function canonical_json(value) + if value === nothing || value === missing + return "null" + elseif value isa Bool + return value ? "true" : "false" + elseif value isa Real + return _canonical_number(value) + elseif value isa AbstractString + return "\"" * _json_escape(value) * "\"" + elseif value isa Symbol || value isa VersionNumber || value isa UUID || value isa Date || value isa DateTime + return canonical_json(string(value)) + elseif value isa Pair + return "[" * canonical_json(first(value)) * "," * canonical_json(last(value)) * "]" + elseif value isa NamedTuple + return canonical_json(Dict(String(key) => getfield(value, key) for key in keys(value))) + elseif value isa AbstractDict + keys_sorted = sort!(String.(collect(keys(value)))) + entries = String[] + for key in keys_sorted + original_key = findfirst(candidate -> String(candidate) == key, collect(keys(value))) + key_object = collect(keys(value))[original_key] + push!(entries, canonical_json(key) * ":" * canonical_json(value[key_object])) + end + return "{" * join(entries, ",") * "}" + elseif value isa AbstractVector || value isa Tuple || value isa Set + values = value isa Set ? sort!(collect(value); by = string) : collect(value) + return "[" * join(canonical_json.(values), ",") * "]" + elseif isstructtype(typeof(value)) + data = Dict{String,Any}() + for field in fieldnames(typeof(value)) + data[String(field)] = getfield(value, field) + end + data["__type__"] = String(nameof(typeof(value))) + return canonical_json(data) + else + return canonical_json(string(value)) + end +end + +hash_payload(value) = bytes2hex(sha256(codeunits(canonical_json(value)))) + +function report_semantic_data(report::ReportDocument) + return Dict( + "id" => report.id, + "revision" => report.revision, + "schema_version" => string(report.schema_version), + "metadata" => report.metadata, + "bindings" => report.bindings, + "dependencies" => report.dependencies, + "sections" => report.sections, + "supersedes" => report.supersedes, + ) +end + +content_hash(report::ReportDocument) = hash_payload(report_semantic_data(report)) + +function _artifact_hash(path::AbstractString) + open(path, "r") do io + return bytes2hex(sha256(io)) + end +end + +_is_sha256(value::AbstractString) = occursin(r"^[0-9a-f]{64}$", lowercase(value)) diff --git a/AIMORAReporting.jl/src/providers.jl b/AIMORAReporting.jl/src/providers.jl new file mode 100644 index 0000000..da3f743 --- /dev/null +++ b/AIMORAReporting.jl/src/providers.jl @@ -0,0 +1,248 @@ +const _REPORT_PROVIDERS = Dict{Symbol,Function}() + +const _PROVIDER_REQUIRED_ROLES = Dict{Symbol,Vector{String}}( + :emt => ["executive-summary", "study-definition", "method", "results", "quality", "limitations", "provenance"], + :line_constants => ["executive-summary", "geometry", "results", "quality", "limitations", "provenance"], + :cable_constants => ["executive-summary", "geometry", "results", "quality", "limitations", "provenance"], + :transformer_parameters => ["executive-summary", "test-data", "results", "quality", "limitations", "provenance"], + :validation => ["executive-summary", "study-definition", "results", "quality", "limitations", "provenance"], + :combined => ["executive-summary", "dependency-graph", "study-comparison", "consolidated-findings", "limitations", "provenance"], +) + +function register_provider!(family::Symbol, provider::Function; replace = false) + if haskey(_REPORT_PROVIDERS, family) && !replace + throw(ArgumentError("a report provider is already registered for $family")) + end + _REPORT_PROVIDERS[family] = provider + return family +end + +available_providers() = sort!(collect(keys(_REPORT_PROVIDERS)); by = string) +required_section_roles(family::Symbol) = copy(get(_PROVIDER_REQUIRED_ROLES, family, String[])) + +_payload_get(payload::AbstractDict, key::AbstractString, default) = + haskey(payload, key) ? payload[key] : haskey(payload, Symbol(key)) ? payload[Symbol(key)] : default +_payload_get(payload::NamedTuple, key::AbstractString, default) = + hasproperty(payload, Symbol(key)) ? getproperty(payload, Symbol(key)) : default +_payload_get(payload, key::AbstractString, default) = default + +function _text_list(value) + value === nothing && return String[] + value isa AbstractString && return [String(value)] + return String.(collect(value)) +end + +function _pairs(value) + value isa AbstractDict && return Pair{String,Any}[String(key) => item for (key, item) in value] + value isa NamedTuple && return Pair{String,Any}[String(key) => getproperty(value, key) for key in keys(value)] + value isa AbstractVector{<:Pair} && return Pair{String,Any}[String(first(item)) => last(item) for item in value] + return Pair{String,Any}[] +end + +function _binding_provenance(binding::ResultBinding) + return Pair{String,Any}[ + "project_id" => binding.project_id, + "project_revision" => binding.project_revision, + "scenario_id" => binding.scenario_id, + "study_id" => binding.study_id, + "study_family" => binding.study_family, + "result_id" => binding.result_id, + "result_schema" => binding.result_schema, + "solver_revision" => binding.solver_revision, + "payload_hash" => binding.payload_hash, + "upstream_hashes" => binding.upstream_hashes, + "units_base" => binding.units_base, + ] +end + +function _normalise_tables(value) + value === nothing && return ReportTable[] + value isa ReportTable && return [value] + tables = ReportTable[] + for (index, item) in enumerate(value) + if item isa ReportTable + push!(tables, item) + elseif item isa AbstractDict || item isa NamedTuple + columns = _payload_get(item, "columns", String[]) + rows = _payload_get(item, "rows", Vector{Any}[]) + push!( + tables, + ReportTable( + _payload_get(item, "id", "table-$index"), + _payload_get(item, "title", "Result table $index"), + columns, + rows; + units = _payload_get(item, "units", fill("", length(columns))), + notes = _text_list(_payload_get(item, "notes", String[])), + source_ids = _text_list(_payload_get(item, "source_ids", String[])), + ), + ) + end + end + return tables +end + +function _normalise_figures(value) + value === nothing && return FigureBlock[] + value isa FigureBlock && return [value] + return FigureBlock[item for item in value if item isa FigureBlock] +end + +function _study_provider(family::Symbol, metadata, bindings, payload; dependencies = Dict{String,Vector{String}}()) + isempty(bindings) && throw(ArgumentError("a report requires at least one immutable result binding")) + summary = String(_payload_get(payload, "summary", "No executive summary was supplied.")) + method = String(_payload_get(payload, "method", "The calculation method is recorded by the bound typed result.")) + assumptions = _text_list(_payload_get(payload, "assumptions", bindings[1].assumptions)) + limitations = _text_list(_payload_get(payload, "limitations", bindings[1].validity_domain)) + inputs = _pairs(_payload_get(payload, "inputs", Pair{String,Any}[])) + quality = _pairs(_payload_get(payload, "quality", Pair{String,Any}[])) + findings = _text_list(_payload_get(payload, "findings", String[])) + tables = _normalise_tables(_payload_get(payload, "tables", ReportTable[])) + figures = _normalise_figures(_payload_get(payload, "figures", FigureBlock[])) + + sections = ReportSection[ + ReportSection( + "section-executive-summary", + "executive-summary", + "Executive summary", + [NarrativeBlock("summary", "Summary", summary)], + ), + ReportSection( + "section-study-definition", + family in (:line_constants, :cable_constants) ? "geometry" : + family == :transformer_parameters ? "test-data" : "study-definition", + family in (:line_constants, :cable_constants) ? "Geometry and study definition" : + family == :transformer_parameters ? "Test data and study definition" : "Study definition", + [KeyValueBlock("study-inputs", "Inputs", inputs)], + ), + ReportSection( + "section-method", + "method", + "Method", + [NarrativeBlock("method", "Method", method)], + ), + ReportSection( + "section-results", + "results", + "Results", + AbstractReportBlock[vcat(tables, figures)...], + ), + ReportSection( + "section-quality", + "quality", + "Numerical quality and checks", + [KeyValueBlock("quality-metrics", "Quality metrics", quality)], + ), + ReportSection( + "section-findings", + "findings", + "Findings", + [NarrativeBlock("findings", "Findings", isempty(findings) ? "No findings were supplied." : join("• " .* findings, "\n"))], + ), + ReportSection( + "section-limitations", + "limitations", + "Assumptions and limitations", + [ + NarrativeBlock("assumptions", "Assumptions", isempty(assumptions) ? "None declared." : join("• " .* assumptions, "\n")), + NarrativeBlock("limitations", "Limitations", isempty(limitations) ? "None declared." : join("• " .* limitations, "\n")), + ], + ), + ReportSection( + "section-provenance", + "provenance", + "Traceability and provenance", + [KeyValueBlock("result-binding-$index", "Bound result $index", _binding_provenance(binding)) for (index, binding) in enumerate(bindings)], + ), + ] + + return ReportDocument( + _payload_get(payload, "report_id", "$(family)-report"), + metadata, + bindings, + sections; + revision = Int(_payload_get(payload, "revision", 1)), + dependencies = dependencies, + ) +end + +function _combined_provider(metadata, bindings, payload; dependencies = Dict{String,Vector{String}}()) + isempty(bindings) && throw(ArgumentError("a combined report requires bound upstream results")) + dependency_rows = Vector{Any}[] + for result_id in sort!(collect(keys(dependencies))) + parents = dependencies[result_id] + push!(dependency_rows, Any[result_id, join(parents, ", ")]) + end + comparison_tables = _normalise_tables(_payload_get(payload, "tables", ReportTable[])) + sections = ReportSection[ + ReportSection( + "section-executive-summary", + "executive-summary", + "Executive summary", + [NarrativeBlock("summary", "Summary", String(_payload_get(payload, "summary", "Combined AIMORA study report.")))], + ), + ReportSection( + "section-dependency-graph", + "dependency-graph", + "Calculation dependency graph", + [ReportTable("dependency-dag", "Result dependencies", ["result_id", "upstream_result_ids"], dependency_rows; units = ["", ""])], + ), + ReportSection( + "section-study-comparison", + "study-comparison", + "Study comparison", + AbstractReportBlock[comparison_tables...], + ), + ReportSection( + "section-consolidated-findings", + "consolidated-findings", + "Consolidated findings", + [NarrativeBlock("findings", "Findings", join("• " .* _text_list(_payload_get(payload, "findings", ["No consolidated findings were supplied."])), "\n"))], + ), + ReportSection( + "section-limitations", + "limitations", + "Representation and domain limitations", + [NarrativeBlock("limitations", "Limitations", join("• " .* _text_list(_payload_get(payload, "limitations", ["No combined-study limitations were supplied."])), "\n"))], + ), + ReportSection( + "section-provenance", + "provenance", + "Traceability and provenance", + [KeyValueBlock("result-binding-$index", "Bound result $index", _binding_provenance(binding)) for (index, binding) in enumerate(bindings)], + ), + ] + return ReportDocument( + _payload_get(payload, "report_id", "combined-report"), + metadata, + bindings, + sections; + revision = Int(_payload_get(payload, "revision", 1)), + dependencies = dependencies, + ) +end + +function build_report( + family::Symbol, + metadata::ReportMetadata, + bindings::AbstractVector{<:ResultBinding}, + payload; + dependencies = Dict{String,Vector{String}}(), +) + provider = get(_REPORT_PROVIDERS, family, nothing) + isnothing(provider) && throw(ArgumentError("no report provider is registered for $family")) + report = provider(metadata, bindings, payload; dependencies = dependencies) + issues = qa_report(report; required_roles = required_section_roles(family)).issues + blocking = filter(issue -> issue.severity in (:error, :fatal), issues) + isempty(blocking) || throw(ArgumentError("report provider produced an invalid semantic report: " * join(issue.message for issue in blocking, "; "))) + return report +end + +function _register_default_providers!() + for family in (:emt, :line_constants, :cable_constants, :transformer_parameters, :validation) + _REPORT_PROVIDERS[family] = (metadata, bindings, payload; dependencies = Dict{String,Vector{String}}()) -> + _study_provider(family, metadata, bindings, payload; dependencies = dependencies) + end + _REPORT_PROVIDERS[:combined] = _combined_provider + return nothing +end diff --git a/AIMORAReporting.jl/src/qa.jl b/AIMORAReporting.jl/src/qa.jl new file mode 100644 index 0000000..6fad44c --- /dev/null +++ b/AIMORAReporting.jl/src/qa.jl @@ -0,0 +1,201 @@ +ispassing(report::QAReport) = all(issue -> !(issue.severity in (:error, :fatal)), report.issues) + +function _issue!(issues, severity, code, component, message) + severity in _QA_SEVERITIES || throw(ArgumentError("unsupported QA severity: $severity")) + push!(issues, QAIssue(severity, code, String(component), String(message))) + return nothing +end + +function _check_finite!(issues, values, component, name) + for (index, value) in enumerate(values) + if value isa Real && !isfinite(value) + _issue!(issues, :error, :nonfinite_value, component, "$name contains a non-finite value at index $index") + end + end +end + +function _check_table!(issues, table::ReportTable) + isempty(table.columns) && _issue!(issues, :error, :empty_table_schema, table.id, "table has no columns") + length(table.column_types) == length(table.columns) || + _issue!(issues, :error, :table_type_shape, table.id, "column_types does not match the column count") + length(table.units) == length(table.columns) || + _issue!(issues, :error, :table_unit_shape, table.id, "units does not match the column count") + isempty(table.rows) && _issue!(issues, :warning, :empty_table, table.id, "table contains no rows") + + for (row_index, row) in enumerate(table.rows) + length(row) == length(table.columns) || begin + _issue!(issues, :error, :table_row_shape, table.id, "row $row_index does not match the column count") + continue + end + for column_index in eachindex(row) + value = row[column_index] + declared = table.column_types[column_index] + if declared !== Any && value !== missing && !(value isa declared) + _issue!( + issues, + :error, + :table_type_mismatch, + table.id, + "row $row_index column $(table.columns[column_index]) does not match $(declared)", + ) + end + if value isa Real && !isfinite(value) + _issue!(issues, :error, :nonfinite_value, table.id, "row $row_index column $(table.columns[column_index]) is non-finite") + end + end + end +end + +function _check_plot!(issues, figure::FigureBlock) + plot = figure.plot + isempty(strip(figure.caption)) && _issue!(issues, :error, :missing_caption, figure.id, "figure caption is empty") + isempty(strip(figure.alt_text)) && _issue!(issues, :error, :missing_alt_text, figure.id, "figure alternative text is empty") + isempty(plot.series) && _issue!(issues, :error, :empty_plot, plot.id, "plot has no series") + plot.clipped && _issue!(issues, :warning, :clipped_plot, plot.id, "plot declares clipped data; the report must explain the clipping") + + style_keys = String[] + for series in plot.series + push!(style_keys, series.style_key) + length(series.x) == length(series.y) || + _issue!(issues, :error, :series_shape, series.id, "x and y lengths differ") + isempty(series.x) && _issue!(issues, :error, :empty_series, series.id, "series is empty") + _check_finite!(issues, series.x, series.id, "x") + _check_finite!(issues, series.y, series.id, "y") + issorted(series.x) || _issue!(issues, :error, :nonmonotonic_axis, series.id, "x coordinates are not monotonic") + plot.xaxis.scale == :log10 && any(value -> !(isfinite(value) && value > 0), series.x) && + _issue!(issues, :error, :invalid_log_domain, series.id, "x data contains values outside the log10 domain") + plot.yaxis.scale == :log10 && any(value -> !(isfinite(value) && value > 0), series.y) && + _issue!(issues, :error, :invalid_log_domain, series.id, "y data contains values outside the log10 domain") + isempty(series.x_unit) && isempty(plot.xaxis.unit) && + _issue!(issues, :warning, :missing_unit, series.id, "x unit is not declared") + isempty(series.y_unit) && isempty(plot.yaxis.unit) && + _issue!(issues, :warning, :missing_unit, series.id, "y unit is not declared") + !isempty(series.source_hash) && !_is_sha256(series.source_hash) && + _issue!(issues, :error, :invalid_source_hash, series.id, "series source hash is not SHA-256") + for event in series.event_indices + 1 <= event <= length(series.x) || + _issue!(issues, :error, :invalid_event_index, series.id, "event index $event is outside the series") + end + if xor(isnothing(series.uncertainty_lower), isnothing(series.uncertainty_upper)) + _issue!(issues, :error, :incomplete_uncertainty, series.id, "both lower and upper uncertainty bounds are required") + elseif !isnothing(series.uncertainty_lower) + length(series.uncertainty_lower) == length(series.y) || + _issue!(issues, :error, :uncertainty_shape, series.id, "lower uncertainty length differs from y") + length(series.uncertainty_upper) == length(series.y) || + _issue!(issues, :error, :uncertainty_shape, series.id, "upper uncertainty length differs from y") + if length(series.uncertainty_lower) == length(series.y) == length(series.uncertainty_upper) + for index in eachindex(series.y) + series.uncertainty_lower[index] <= series.y[index] <= series.uncertainty_upper[index] || + _issue!(issues, :error, :invalid_uncertainty, series.id, "uncertainty does not enclose y at index $index") + end + end + end + end + length(unique(style_keys)) == length(style_keys) || + _issue!(issues, :warning, :duplicate_visual_style, plot.id, "multiple series use the same style key") +end + +function _dependency_cycles(dependencies::Dict{String,Vector{String}}) + visiting = Set{String}() + visited = Set{String}() + cycles = Vector{Vector{String}}() + stack = String[] + + function visit(node) + node in visited && return + if node in visiting + start_index = findfirst(==(node), stack) + push!(cycles, vcat(stack[start_index:end], node)) + return + end + push!(visiting, node) + push!(stack, node) + for parent in get(dependencies, node, String[]) + haskey(dependencies, parent) && visit(parent) + end + pop!(stack) + delete!(visiting, node) + push!(visited, node) + end + + for node in keys(dependencies) + visit(node) + end + return cycles +end + +function qa_report(report::ReportDocument; required_roles = String[]) + issues = QAIssue[] + isempty(strip(report.id)) && _issue!(issues, :error, :missing_report_id, "report", "report id is empty") + report.revision >= 1 || _issue!(issues, :error, :invalid_revision, report.id, "report revision must be positive") + report.state in _REPORT_STATES || _issue!(issues, :error, :invalid_state, report.id, "report state is invalid") + isempty(report.bindings) && _issue!(issues, :error, :missing_result_binding, report.id, "report has no immutable result binding") + isempty(report.sections) && _issue!(issues, :error, :empty_report, report.id, "report has no sections") + + result_ids = Set(binding.result_id for binding in report.bindings) + for binding in report.bindings + _is_sha256(binding.payload_hash) || + _issue!(issues, :error, :invalid_result_hash, binding.result_id, "bound payload hash is not SHA-256") + for upstream in binding.upstream_hashes + _is_sha256(upstream) || _issue!(issues, :error, :invalid_upstream_hash, binding.result_id, "upstream hash is not SHA-256") + end + isempty(strip(binding.units_base)) && _issue!(issues, :error, :missing_unit_base, binding.result_id, "units base is empty") + end + + component_ids = String[] + roles = String[] + for section in report.sections + push!(component_ids, section.id) + push!(roles, section.role) + isempty(strip(section.role)) && _issue!(issues, :error, :missing_section_role, section.id, "section role is empty") + isempty(section.blocks) && _issue!(issues, :warning, :empty_section, section.id, "section has no content blocks") + for block in section.blocks + push!(component_ids, getfield(block, :id)) + if block isa ReportTable + _check_table!(issues, block) + elseif block isa FigureBlock + _check_plot!(issues, block) + elseif block isa NarrativeBlock + isempty(strip(block.text)) && _issue!(issues, :warning, :empty_narrative, block.id, "narrative block is empty") + elseif block isa DiagnosticBlock + block.severity in (:info, :warning, :error, :fatal) || + _issue!(issues, :error, :invalid_diagnostic_severity, block.id, "diagnostic severity is invalid") + end + end + end + length(component_ids) == length(unique(component_ids)) || + _issue!(issues, :error, :duplicate_component_id, report.id, "report component IDs are not unique") + + for role in required_roles + role in roles || _issue!(issues, :error, :missing_required_section, report.id, "required section role '$role' is absent") + end + + for (node, parents) in report.dependencies + node in result_ids || _issue!(issues, :error, :unknown_dependency_node, node, "dependency node is not a bound result") + for parent in parents + parent in result_ids || _issue!(issues, :error, :unknown_dependency_parent, node, "dependency parent '$parent' is not a bound result") + end + end + for cycle in _dependency_cycles(report.dependencies) + _issue!(issues, :error, :dependency_cycle, report.id, "result dependency cycle: " * join(cycle, " → ")) + end + + for comment in report.comments + if comment.status != :resolved && comment.severity in (:error, :fatal, :critical) + _issue!(issues, :error, :unresolved_review_comment, comment.component_id, "unresolved blocking review comment $(comment.id)") + end + end + + if report.state in (:approved, :frozen) + current_hash = content_hash(report) + any(approval -> approval.action == :approve && approval.report_hash == current_hash, report.approvals) || + _issue!(issues, :error, :missing_current_approval, report.id, "no approval is bound to the current report content") + end + if report.state == :frozen + isnothing(report.frozen_hash) && _issue!(issues, :error, :missing_freeze_hash, report.id, "frozen report has no freeze hash") + !isnothing(report.frozen_hash) && report.frozen_hash != content_hash(report) && + _issue!(issues, :fatal, :frozen_content_changed, report.id, "frozen report content no longer matches its freeze hash") + end + + return QAReport(report.id, content_hash(report), issues) +end diff --git a/AIMORAReporting.jl/src/renderers.jl b/AIMORAReporting.jl/src/renderers.jl new file mode 100644 index 0000000..3ff065c --- /dev/null +++ b/AIMORAReporting.jl/src/renderers.jl @@ -0,0 +1,430 @@ +function _html_escape(value) + io = IOBuffer() + for character in String(value) + if character == '&' + print(io, "&") + elseif character == '<' + print(io, "<") + elseif character == '>' + print(io, ">") + elseif character == '"' + print(io, """) + elseif character == '\'' + print(io, "'") + else + print(io, character) + end + end + return String(take!(io)) +end + +function _tex_escape(value) + io = IOBuffer() + for character in String(value) + if character == '\\' + print(io, raw"\textbackslash{}") + elseif character == '&' + print(io, raw"\&") + elseif character == '%' + print(io, raw"\%") + elseif character == '$' + print(io, raw"\$") + elseif character == '#' + print(io, raw"\#") + elseif character == '_' + print(io, raw"\_") + elseif character == '{' + print(io, raw"\{") + elseif character == '}' + print(io, raw"\}") + elseif character == '~' + print(io, raw"\textasciitilde{}") + elseif character == '^' + print(io, raw"\textasciicircum{}") + else + print(io, character) + end + end + return String(take!(io)) +end + +function _display_value(value) + value === missing && return "—" + value === nothing && return "—" + value isa AbstractFloat && return @sprintf("%.8g", value) + value isa AbstractVector && return join(_display_value.(value), ", ") + return string(value) +end + +function _markdown_block(block::NarrativeBlock) + return "### $(block.heading)\n\n$(block.text)\n" +end + +function _markdown_block(block::EquationBlock) + definitions = isempty(block.definitions) ? "" : "\n" * join("- `$(key)`: $(value)" for (key, value) in block.definitions, "\n") * "\n" + return "### $(block.label)\n\n```math\n$(block.expression)\n```\n" * definitions +end + +function _markdown_block(block::KeyValueBlock) + lines = ["| Property | Value | Unit |", "| --- | ---: | --- |"] + for (key, value) in block.entries + push!(lines, "| $(key) | $(_display_value(value)) | $(get(block.units, key, "")) |") + end + return "### $(block.title)\n\n" * join(lines, "\n") * "\n" +end + +function _markdown_block(table::ReportTable) + header = "| " * join(table.columns, " | ") * " |" + separator = "| " * join(fill("---", length(table.columns)), " | ") * " |" + unit_row = any(!isempty, table.units) ? "| " * join([isempty(unit) ? "" : "[$unit]" for unit in table.units], " | ") * " |" : "" + rows = ["| " * join(_display_value.(row), " | ") * " |" for row in table.rows] + notes = isempty(table.notes) ? "" : "\n" * join("- " .* table.notes, "\n") * "\n" + return "### $(table.title)\n\n" * join(filter(value -> !isempty(value), vcat([header, separator, unit_row], rows)), "\n") * "\n" * notes +end + +function _markdown_block(figure::FigureBlock) + return "### $(figure.plot.title)\n\n![${figure.alt_text}](figures/$(figure.id).svg)\n\n*$(figure.caption)*\n" +end + +function _markdown_block(block::DiagnosticBlock) + return "> **$(uppercase(String(block.severity))) — $(block.code):** $(block.message)\n" +end + +function render_markdown(report::ReportDocument) + io = IOBuffer() + println(io, "# ", report.metadata.title) + !isempty(report.metadata.subtitle) && println(io, "\n", report.metadata.subtitle) + println(io, "\n- Report ID: `", report.id, "`") + println(io, "- Revision: ", report.revision) + println(io, "- State: `", report.state, "`") + println(io, "- Content SHA-256: `", content_hash(report), "`") + for section in report.sections + println(io, "\n## ", section.title, "\n") + for block in section.blocks + println(io, _markdown_block(block)) + end + end + return String(take!(io)) +end + +function _html_block(block::NarrativeBlock) + paragraphs = join("

" * _html_escape(line) * "

" for line in split(block.text, '\n') if !isempty(strip(line))) + return "

$(_html_escape(block.heading))

$paragraphs
" +end + +function _html_block(block::EquationBlock) + definitions = isempty(block.definitions) ? "" : "
" * join("
$(_html_escape(key))
$(_html_escape(value))
" for (key, value) in block.definitions) * "
" + return "

$(_html_escape(block.label))

$(_html_escape(block.expression))
$definitions
" +end + +function _html_block(block::KeyValueBlock) + rows = join("$(_html_escape(key))$(_html_escape(_display_value(value)))$(_html_escape(get(block.units, key, "")))" for (key, value) in block.entries) + return "

$(_html_escape(block.title))

$rows
PropertyValueUnit
" +end + +function _html_block(table::ReportTable) + headers = join("$(_html_escape(column))" for column in table.columns) + units = join("$(_html_escape(unit))" for unit in table.units) + rows = join("" * join("$(_html_escape(_display_value(value)))" for value in row) * "" for row in table.rows) + unit_row = any(!isempty, table.units) ? "$units" : "" + return "

$(_html_escape(table.title))

$headers$unit_row$rows
" +end + +function _html_block(figure::FigureBlock) + return "
\"$(_html_escape(figure.alt_text))\"
$(_html_escape(figure.caption))
" +end + +function _html_block(block::DiagnosticBlock) + return "" +end + +function render_html(report::ReportDocument) + body = IOBuffer() + println(body, "

", _html_escape(report.metadata.title), "

", _html_escape(report.metadata.subtitle), "

") + println(body, "
Report ID
", _html_escape(report.id), "
Revision
", report.revision, "
Content hash
", content_hash(report), "
") + for section in report.sections + println(body, "

", _html_escape(section.title), "

") + for block in section.blocks + println(body, _html_block(block)) + end + println(body, "
") + end + content = String(take!(body)) + return "$(_html_escape(report.metadata.title))$content" +end + +function render_text(report::ReportDocument) + markdown = render_markdown(report) + text = replace(markdown, r"```math\n?" => "", "```" => "", r"[#*_`]" => "") + return replace(text, r"!\[([^\]]*)\]\([^\)]*\)" => s"Figure: \1") +end + +render_json(report::ReportDocument) = canonical_json(report_semantic_data(report)) * "\n" + +function _tex_block(block::NarrativeBlock) + return "\\subsection*{$(_tex_escape(block.heading))}\n$(_tex_escape(block.text))\n" +end + +function _tex_block(block::EquationBlock) + definitions = isempty(block.definitions) ? "" : "\\begin{description}\n" * join("\\item[$(_tex_escape(key))] $(_tex_escape(value))" for (key, value) in block.definitions, "\n") * "\n\\end{description}\n" + return "\\subsection*{$(_tex_escape(block.label))}\n\\begin{equation}\n$(block.expression)\n\\end{equation}\n$definitions" +end + +function _tex_block(block::KeyValueBlock) + rows = join("$(_tex_escape(key)) & $(_tex_escape(_display_value(value))) & $(_tex_escape(get(block.units, key, ""))) \\\\" for (key, value) in block.entries, "\n") + return "\\subsection*{$(_tex_escape(block.title))}\n\\begin{longtable}{p{0.35\\linewidth}p{0.4\\linewidth}p{0.15\\linewidth}}\nProperty & Value & Unit \\\\ \\hline\n$rows\n\\end{longtable}\n" +end + +function _tex_block(table::ReportTable) + specification = join(fill("p{" * string(round(0.9 / max(length(table.columns), 1); digits = 3)) * "\\linewidth}", length(table.columns))) + header = join(_tex_escape.(table.columns), " & ") * " \\\\ \\hline" + unit_row = any(!isempty, table.units) ? join([isempty(unit) ? "" : "[$(_tex_escape(unit))]" for unit in table.units], " & ") * " \\\\" : "" + rows = join(join(_tex_escape.(_display_value.(row)), " & ") * " \\\\" for row in table.rows, "\n") + return "\\subsection*{$(_tex_escape(table.title))}\n\\begin{longtable}{$specification}\n$header\n$unit_row\n$rows\n\\end{longtable}\n" +end + +function _tex_block(figure::FigureBlock) + return "\\begin{figure}[htbp]\n\\centering\n\\includegraphics[width=0.95\\linewidth]{figures/$(_tex_escape(figure.id)).svg}\n\\caption{$(_tex_escape(figure.caption))}\n\\label{fig:$(_tex_escape(figure.id))}\n\\end{figure}\n" +end + +function _tex_block(block::DiagnosticBlock) + return "\\paragraph{$(_tex_escape(uppercase(String(block.severity)))): $(_tex_escape(String(block.code)))} $(_tex_escape(block.message))\n" +end + +function render_tex(report::ReportDocument) + io = IOBuffer() + println(io, "\\documentclass[11pt]{article}") + println(io, "\\usepackage[margin=25mm]{geometry}") + println(io, "\\usepackage{longtable,booktabs,graphicx,hyperref}") + println(io, "\\usepackage[T1]{fontenc}") + println(io, "\\title{", _tex_escape(report.metadata.title), "}") + println(io, "\\author{", _tex_escape(join(report.metadata.prepared_by, ", ")), "}") + println(io, "\\date{", _tex_escape(string(report.metadata.issued_at)), "}") + println(io, "\\begin{document}\\maketitle") + println(io, "\\noindent Report ID: \\texttt{", _tex_escape(report.id), "}\\\\") + println(io, "Revision: ", report.revision, "\\\\") + println(io, "Content SHA-256: \\texttt{", content_hash(report), "}") + for section in report.sections + println(io, "\\section{", _tex_escape(section.title), "}") + for block in section.blocks + println(io, _tex_block(block)) + end + end + println(io, "\\end{document}") + return String(take!(io)) +end + +function _csv_escape(value) + text = _display_value(value) + if occursin(',', text) || occursin('"', text) || occursin('\n', text) + return "\"" * replace(text, '"' => "\"\"") * "\"" + end + return text +end + +function render_csv_companions(report::ReportDocument, directory::AbstractString) + mkpath(directory) + paths = String[] + for section in report.sections, block in section.blocks + block isa ReportTable || continue + path = joinpath(directory, "$(block.id).csv") + open(path, "w") do io + println(io, join(_csv_escape.(block.columns), ',')) + any(!isempty, block.units) && println(io, join(_csv_escape.(block.units), ',')) + for row in block.rows + println(io, join(_csv_escape.(row), ',')) + end + end + push!(paths, path) + end + return paths +end + +function _series_svg_points(series::PlotSeries, xbounds, ybounds; width = 900.0, height = 420.0, margin = 55.0) + xmin, xmax = xbounds + ymin, ymax = ybounds + xscale = (width - 2margin) / (xmax - xmin) + yscale = (height - 2margin) / (ymax - ymin) + points = String[] + for (x, y) in zip(series.x, series.y) + isfinite(x) && isfinite(y) || continue + px = margin + (x - xmin) * xscale + py = height - margin - (y - ymin) * yscale + push!(points, @sprintf("%.3f,%.3f", px, py)) + end + return join(points, " ") +end + +function _render_svg(figure::FigureBlock) + all_x = reduce(vcat, (series.x for series in figure.plot.series); init = Float64[]) + all_y = reduce(vcat, (series.y for series in figure.plot.series); init = Float64[]) + xbounds = _finite_bounds(all_x) + ybounds = _finite_bounds(all_y) + width, height, margin = 900.0, 420.0, 55.0 + polylines = String[] + dash_patterns = ("", "8 4", "2 4", "12 4 2 4", "5 3") + for (index, series) in enumerate(figure.plot.series) + points = _series_svg_points(series, xbounds, ybounds; width = width, height = height, margin = margin) + dash = dash_patterns[mod1(index, length(dash_patterns))] + dash_attribute = isempty(dash) ? "" : " stroke-dasharray=\"$dash\"" + push!(polylines, "$(_html_escape(series.label))") + end + return "$(_html_escape(figure.plot.title))$(_html_escape(figure.alt_text))$(join(polylines))$(_html_escape(figure.plot.xaxis.label)) [$(_html_escape(figure.plot.xaxis.unit))]$(_html_escape(figure.plot.yaxis.label)) [$(_html_escape(figure.plot.yaxis.unit))]" +end + +function render_svg_figures(report::ReportDocument, directory::AbstractString) + mkpath(directory) + paths = String[] + for section in report.sections, block in section.blocks + block isa FigureBlock || continue + path = joinpath(directory, "$(block.id).svg") + write(path, _render_svg(block)) + push!(paths, path) + end + return paths +end + +function _render_tikz(figure::FigureBlock) + plots = String[] + for series in figure.plot.series + coordinates = join("($(@sprintf("%.17g", x)),$(@sprintf("%.17g", y)))" for (x, y) in zip(series.x, series.y) if isfinite(x) && isfinite(y), " ") + push!(plots, "\\addplot coordinates {$coordinates};\\addlegendentry{$(_tex_escape(series.label))}") + end + xmode = figure.plot.xaxis.scale == :log10 ? ",xmode=log" : "" + ymode = figure.plot.yaxis.scale == :log10 ? ",ymode=log" : "" + return "\\begin{tikzpicture}\n\\begin{axis}[title={$(_tex_escape(figure.plot.title))},xlabel={$(_tex_escape(figure.plot.xaxis.label)) [$(_tex_escape(figure.plot.xaxis.unit))]},ylabel={$(_tex_escape(figure.plot.yaxis.label)) [$(_tex_escape(figure.plot.yaxis.unit))]}$xmode$ymode]\n$(join(plots, "\n"))\n\\end{axis}\n\\end{tikzpicture}\n" +end + +function render_tikz_figures(report::ReportDocument, directory::AbstractString) + mkpath(directory) + paths = String[] + for section in report.sections, block in section.blocks + block isa FigureBlock || continue + path = joinpath(directory, "$(block.id).tex") + write(path, _render_tikz(block)) + push!(paths, path) + end + return paths +end + +function _write_manifest(path, manifest::PublicationManifest) + document = Dict( + "schema_version" => string(manifest.schema_version), + "report_id" => manifest.report_id, + "report_revision" => manifest.report_revision, + "report_hash" => manifest.report_hash, + "template_hash" => manifest.template_hash, + "generated_at" => string(manifest.generated_at), + "artifacts" => manifest.artifacts, + "warnings" => manifest.warnings, + ) + open(path, "w") do io + TOML.print(io, document; sorted = true) + end + return path +end + +function _prior_generated_files(directory) + manifest_path = joinpath(directory, "manifest.toml") + isfile(manifest_path) || return Set{String}() + document = TOML.parsefile(manifest_path) + artifacts = get(document, "artifacts", Dict{String,Any}()) + return Set(String.(keys(artifacts))) +end + +function _compile_tectonic(tex_path, output_directory) + executable = Sys.which("tectonic") + isnothing(executable) && throw(ToolchainUnavailable("tectonic", "tectonic", "PDF compilation was requested, but Tectonic is unavailable. The portable TeX source remains valid.")) + command = `$(executable) --only-cached --keep-logs --outdir $(output_directory) $(tex_path)` + run(command; wait = true) + return joinpath(output_directory, replace(basename(tex_path), r"\.tex$" => ".pdf")) +end + +function render_bundle( + report::ReportDocument, + output_directory::AbstractString; + formats = [:markdown, :html, :text, :json, :tex, :csv, :svg, :tikz], + template::Union{Nothing,ReportTemplate} = nothing, + compile_pdf = false, + generated_at = DateTime(1970, 1, 1), +) + required_roles = isnothing(template) ? String[] : template.required_roles + quality = qa_report(report; required_roles = required_roles) + ispassing(quality) || throw(ArgumentError("report QA failed: " * join(issue.message for issue in quality.issues if issue.severity in (:error, :fatal), "; "))) + report.state == :frozen && !verify_frozen(report) && throw(ArgumentError("frozen report content hash is invalid")) + + destination = abspath(output_directory) + destination in (abspath("/"), abspath(homedir())) && throw(ArgumentError("unsafe report output directory")) + mkpath(destination) + previous = _prior_generated_files(destination) + warnings = [issue.message for issue in quality.issues if issue.severity == :warning] + template_digest = isnothing(template) ? repeat("0", 64) : template_hash(template) + + return mktempdir() do stage + generated = String[] + if :markdown in formats + write(joinpath(stage, "report.md"), render_markdown(report)); push!(generated, "report.md") + end + if :html in formats + write(joinpath(stage, "report.html"), render_html(report)); push!(generated, "report.html") + end + if :text in formats + write(joinpath(stage, "report.txt"), render_text(report)); push!(generated, "report.txt") + end + if :json in formats + write(joinpath(stage, "report.json"), render_json(report)); push!(generated, "report.json") + end + tex_path = joinpath(stage, "report.tex") + if :tex in formats || compile_pdf + write(tex_path, render_tex(report)); push!(generated, "report.tex") + end + if :csv in formats + for path in render_csv_companions(report, joinpath(stage, "data")) + push!(generated, relpath(path, stage)) + end + end + if :svg in formats + for path in render_svg_figures(report, joinpath(stage, "figures")) + push!(generated, relpath(path, stage)) + end + end + if :tikz in formats + for path in render_tikz_figures(report, joinpath(stage, "tikz")) + push!(generated, relpath(path, stage)) + end + end + if compile_pdf + pdf_path = _compile_tectonic(tex_path, stage) + push!(generated, relpath(pdf_path, stage)) + end + + artifacts = Dict{String,String}() + for relative in sort!(unique(generated)) + artifacts[relative] = _artifact_hash(joinpath(stage, relative)) + end + manifest = PublicationManifest( + REPORT_SCHEMA_VERSION, + report.id, + report.revision, + content_hash(report), + template_digest, + generated_at isa DateTime ? generated_at : DateTime(generated_at), + artifacts, + warnings, + ) + _write_manifest(joinpath(stage, "manifest.toml"), manifest) + + for relative in setdiff(previous, Set(keys(artifacts))) + stale = joinpath(destination, relative) + isfile(stale) && rm(stale) + end + for relative in sort!(collect(keys(artifacts))) + source = joinpath(stage, relative) + target = joinpath(destination, relative) + mkpath(dirname(target)) + cp(source, target; force = true) + end + cp(joinpath(stage, "manifest.toml"), joinpath(destination, "manifest.toml"); force = true) + return manifest + end +end diff --git a/AIMORAReporting.jl/src/templates.jl b/AIMORAReporting.jl/src/templates.jl new file mode 100644 index 0000000..7c4da5c --- /dev/null +++ b/AIMORAReporting.jl/src/templates.jl @@ -0,0 +1,37 @@ +function validate_template(template::ReportTemplate) + issues = String[] + isempty(strip(template.id)) && push!(issues, "template id is empty") + template.version < v"1.0.0" && push!(issues, "template version must be at least 1.0.0") + isempty(strip(template.licence)) && push!(issues, "template licence is missing") + isempty(template.required_roles) && push!(issues, "template declares no required section roles") + isempty(template.output_profiles) && push!(issues, "template declares no output profiles") + isempty(template.source_hash) && push!(issues, "template source hash is missing") + !_is_sha256(template.source_hash) && push!(issues, "template source hash is not SHA-256") + return issues +end + +function load_template(path::AbstractString; trusted = false) + isfile(path) || throw(ArgumentError("template file does not exist: $path")) + document = TOML.parsefile(path) + required_keys = ("id", "version", "licence", "family", "required_roles", "output_profiles") + missing_keys = filter(key -> !haskey(document, key), required_keys) + isempty(missing_keys) || throw(ArgumentError("template is missing required fields: " * join(missing_keys, ", "))) + + template = ReportTemplate( + String(document["id"]), + VersionNumber(document["version"]), + String(document["licence"]), + Symbol(document["family"]), + Symbol.(get(document, "supported_studies", String[])), + String.(document["required_roles"]), + Symbol.(document["output_profiles"]), + Bool(trusted), + abspath(path), + _artifact_hash(path), + ) + issues = validate_template(template) + isempty(issues) || throw(ArgumentError("invalid report template: " * join(issues, "; "))) + return template +end + +template_hash(template::ReportTemplate) = template.source_hash diff --git a/AIMORAReporting.jl/src/types.jl b/AIMORAReporting.jl/src/types.jl new file mode 100644 index 0000000..a31a8f4 --- /dev/null +++ b/AIMORAReporting.jl/src/types.jl @@ -0,0 +1,420 @@ +const REPORT_SCHEMA_VERSION = v"1.0.0" + +const _REPORT_STATES = Set((:draft, :review, :approved, :frozen, :superseded, :withdrawn)) +const _QA_SEVERITIES = Set((:info, :warning, :error, :fatal)) + +abstract type AbstractReportBlock end + +"""Immutable identity and provenance binding for one typed study result.""" +struct ResultBinding + project_id::String + project_revision::String + scenario_id::String + study_id::String + study_family::Symbol + result_id::String + result_schema::VersionNumber + solver_revision::String + payload_hash::String + upstream_hashes::Vector{String} + units_base::String + assumptions::Vector{String} + validity_domain::Vector{String} + warnings::Vector{String} +end + +function ResultBinding(; + project_id, + project_revision, + scenario_id, + study_id, + study_family, + result_id, + result_schema = v"1.0.0", + solver_revision, + payload_hash, + upstream_hashes = String[], + units_base = "SI", + assumptions = String[], + validity_domain = String[], + warnings = String[], +) + isempty(strip(String(project_id))) && throw(ArgumentError("project_id must not be empty")) + isempty(strip(String(result_id))) && throw(ArgumentError("result_id must not be empty")) + return ResultBinding( + String(project_id), + String(project_revision), + String(scenario_id), + String(study_id), + Symbol(study_family), + String(result_id), + result_schema isa VersionNumber ? result_schema : VersionNumber(result_schema), + String(solver_revision), + lowercase(String(payload_hash)), + String.(upstream_hashes), + String(units_base), + String.(assumptions), + String.(validity_domain), + String.(warnings), + ) +end + +"""Document-control metadata independent of any renderer.""" +struct ReportMetadata + title::String + subtitle::String + report_number::String + revision_label::String + confidentiality::Symbol + prepared_by::Vector{String} + checked_by::Vector{String} + approved_by::Vector{String} + organization::String + client::String + issued_at::DateTime + language::String + tags::Vector{String} +end + +function ReportMetadata(; + title, + subtitle = "", + report_number = "", + revision_label = "DRAFT", + confidentiality = :internal, + prepared_by = String[], + checked_by = String[], + approved_by = String[], + organization = "", + client = "", + issued_at = DateTime(1970, 1, 1), + language = "en", + tags = String[], +) + isempty(strip(String(title))) && throw(ArgumentError("report title must not be empty")) + return ReportMetadata( + String(title), + String(subtitle), + String(report_number), + String(revision_label), + Symbol(confidentiality), + String.(prepared_by), + String.(checked_by), + String.(approved_by), + String(organization), + String(client), + issued_at isa DateTime ? issued_at : DateTime(issued_at), + String(language), + String.(tags), + ) +end + +struct NarrativeBlock <: AbstractReportBlock + id::String + heading::String + text::String + source_ids::Vector{String} +end + +NarrativeBlock(id, heading, text; source_ids = String[]) = + NarrativeBlock(String(id), String(heading), String(text), String.(source_ids)) + +struct EquationBlock <: AbstractReportBlock + id::String + label::String + expression::String + definitions::Vector{Pair{String,String}} + source_ids::Vector{String} +end + +EquationBlock(id, label, expression; definitions = Pair{String,String}[], source_ids = String[]) = + EquationBlock( + String(id), + String(label), + String(expression), + Pair{String,String}[String(k) => String(v) for (k, v) in definitions], + String.(source_ids), + ) + +struct KeyValueBlock <: AbstractReportBlock + id::String + title::String + entries::Vector{Pair{String,Any}} + units::Dict{String,String} + source_ids::Vector{String} +end + +KeyValueBlock(id, title, entries; units = Dict{String,String}(), source_ids = String[]) = + KeyValueBlock( + String(id), + String(title), + Pair{String,Any}[String(k) => v for (k, v) in entries], + Dict(String(k) => String(v) for (k, v) in units), + String.(source_ids), + ) + +"""A typed numerical table. Values remain data, not preformatted display strings.""" +struct ReportTable <: AbstractReportBlock + id::String + title::String + columns::Vector{String} + column_types::Vector{DataType} + units::Vector{String} + rows::Vector{Vector{Any}} + notes::Vector{String} + source_ids::Vector{String} +end + +function ReportTable( + id, + title, + columns, + rows; + column_types = DataType[Any for _ in columns], + units = fill("", length(columns)), + notes = String[], + source_ids = String[], +) + return ReportTable( + String(id), + String(title), + String.(columns), + DataType[column_types...], + String.(units), + [Any[row...] for row in rows], + String.(notes), + String.(source_ids), + ) +end + +struct AxisSpec + label::String + unit::String + scale::Symbol + minimum::Union{Nothing,Float64} + maximum::Union{Nothing,Float64} +end + +function AxisSpec(label; unit = "", scale = :linear, minimum = nothing, maximum = nothing) + scale in (:linear, :log10) || throw(ArgumentError("axis scale must be :linear or :log10")) + return AxisSpec( + String(label), + String(unit), + Symbol(scale), + isnothing(minimum) ? nothing : Float64(minimum), + isnothing(maximum) ? nothing : Float64(maximum), + ) +end + +struct PlotSeries + id::String + label::String + x::Vector{Float64} + y::Vector{Float64} + x_unit::String + y_unit::String + source_hash::String + event_indices::Vector{Int} + uncertainty_lower::Union{Nothing,Vector{Float64}} + uncertainty_upper::Union{Nothing,Vector{Float64}} + style_key::String +end + +function PlotSeries( + id, + label, + x, + y; + x_unit = "", + y_unit = "", + source_hash = "", + event_indices = Int[], + uncertainty_lower = nothing, + uncertainty_upper = nothing, + style_key = "series-1", +) + return PlotSeries( + String(id), + String(label), + Float64.(x), + Float64.(y), + String(x_unit), + String(y_unit), + lowercase(String(source_hash)), + Int.(event_indices), + isnothing(uncertainty_lower) ? nothing : Float64.(uncertainty_lower), + isnothing(uncertainty_upper) ? nothing : Float64.(uncertainty_upper), + String(style_key), + ) +end + +struct PlotSpec + id::String + title::String + xaxis::AxisSpec + yaxis::AxisSpec + series::Vector{PlotSeries} + annotations::Vector{Pair{Float64,String}} + transforms::Vector{String} + clipped::Bool +end + +PlotSpec(id, title, xaxis, yaxis, series; annotations = Pair{Float64,String}[], transforms = String[], clipped = false) = + PlotSpec( + String(id), + String(title), + xaxis, + yaxis, + PlotSeries[series...], + Pair{Float64,String}[Float64(k) => String(v) for (k, v) in annotations], + String.(transforms), + Bool(clipped), + ) + +struct FigureBlock <: AbstractReportBlock + id::String + caption::String + alt_text::String + plot::PlotSpec + source_ids::Vector{String} +end + +FigureBlock(id, caption, alt_text, plot; source_ids = String[]) = + FigureBlock(String(id), String(caption), String(alt_text), plot, String.(source_ids)) + +struct DiagnosticBlock <: AbstractReportBlock + id::String + severity::Symbol + code::Symbol + message::String + evidence_ids::Vector{String} +end + +DiagnosticBlock(id, severity, code, message; evidence_ids = String[]) = + DiagnosticBlock(String(id), Symbol(severity), Symbol(code), String(message), String.(evidence_ids)) + +struct ReportSection + id::String + role::String + title::String + blocks::Vector{AbstractReportBlock} +end + +ReportSection(id, role, title, blocks = AbstractReportBlock[]) = + ReportSection(String(id), String(role), String(title), AbstractReportBlock[blocks...]) + +mutable struct ReviewComment + id::String + component_id::String + author::String + severity::Symbol + text::String + status::Symbol + created_at::DateTime + resolved_at::Union{Nothing,DateTime} + resolution::String +end + +struct ApprovalRecord + id::String + role::Symbol + actor::String + action::Symbol + timestamp::DateTime + report_hash::String +end + +mutable struct ReportDocument + id::String + revision::Int + schema_version::VersionNumber + metadata::ReportMetadata + bindings::Vector{ResultBinding} + dependencies::Dict{String,Vector{String}} + sections::Vector{ReportSection} + state::Symbol + comments::Vector{ReviewComment} + approvals::Vector{ApprovalRecord} + frozen_hash::Union{Nothing,String} + supersedes::Union{Nothing,String} + superseded_by::Union{Nothing,String} +end + +function ReportDocument( + id, + metadata, + bindings, + sections; + revision = 1, + schema_version = REPORT_SCHEMA_VERSION, + dependencies = Dict{String,Vector{String}}(), + state = :draft, + comments = ReviewComment[], + approvals = ApprovalRecord[], + frozen_hash = nothing, + supersedes = nothing, + superseded_by = nothing, +) + state in _REPORT_STATES || throw(ArgumentError("unsupported report state: $state")) + return ReportDocument( + String(id), + Int(revision), + schema_version isa VersionNumber ? schema_version : VersionNumber(schema_version), + metadata, + ResultBinding[bindings...], + Dict(String(k) => String.(v) for (k, v) in dependencies), + ReportSection[sections...], + Symbol(state), + ReviewComment[comments...], + ApprovalRecord[approvals...], + isnothing(frozen_hash) ? nothing : String(frozen_hash), + isnothing(supersedes) ? nothing : String(supersedes), + isnothing(superseded_by) ? nothing : String(superseded_by), + ) +end + +struct QAIssue + severity::Symbol + code::Symbol + component_id::String + message::String +end + +struct QAReport + report_id::String + report_hash::String + issues::Vector{QAIssue} +end + +struct ReportTemplate + id::String + version::VersionNumber + licence::String + family::Symbol + supported_studies::Vector{Symbol} + required_roles::Vector{String} + output_profiles::Vector{Symbol} + trusted::Bool + source_path::String + source_hash::String +end + +struct PublicationManifest + schema_version::VersionNumber + report_id::String + report_revision::Int + report_hash::String + template_hash::String + generated_at::DateTime + artifacts::Dict{String,String} + warnings::Vector{String} +end + +struct ToolchainUnavailable <: Exception + tool::String + profile::String + message::String +end + +Base.showerror(io::IO, error::ToolchainUnavailable) = + print(io, error.message, " [tool=", error.tool, ", profile=", error.profile, "]") diff --git a/AIMORAReporting.jl/src/visuals.jl b/AIMORAReporting.jl/src/visuals.jl new file mode 100644 index 0000000..97c6860 --- /dev/null +++ b/AIMORAReporting.jl/src/visuals.jl @@ -0,0 +1,83 @@ +""" +Return deterministic sample indices that preserve boundaries, declared events, +neighbours of events, and local extrema. The returned set may exceed `target` +when the mandatory event set itself is larger than the requested budget. +""" +function event_preserving_indices( + x::AbstractVector, + y::AbstractVector, + target::Integer; + events = Int[], +) + length(x) == length(y) || throw(ArgumentError("x and y lengths differ")) + count = length(x) + count == 0 && return Int[] + target >= 2 || throw(ArgumentError("target must be at least 2")) + count <= target && return collect(1:count) + + mandatory = Set{Int}((1, count)) + for event in events + 1 <= event <= count || throw(ArgumentError("event index $event is outside the series")) + for candidate in (event - 1, event, event + 1) + 1 <= candidate <= count && push!(mandatory, candidate) + end + end + + optional = Set{Int}() + bucket_count = max(1, fld(max(target - length(mandatory), 2), 2)) + edges = round.(Int, range(1, count + 1; length = bucket_count + 1)) + for bucket in 1:bucket_count + first_index = clamp(edges[bucket], 1, count) + last_index = clamp(edges[bucket + 1] - 1, first_index, count) + range_indices = first_index:last_index + local_values = y[range_indices] + finite_positions = findall(isfinite, local_values) + isempty(finite_positions) && continue + minimum_position = finite_positions[argmin(local_values[finite_positions])] + maximum_position = finite_positions[argmax(local_values[finite_positions])] + push!(optional, first_index + minimum_position - 1) + push!(optional, first_index + maximum_position - 1) + end + + candidates = sort!(collect(setdiff(optional, mandatory))) + allowance = max(0, target - length(mandatory)) + if length(candidates) > allowance && allowance > 0 + chosen_positions = unique(round.(Int, range(1, length(candidates); length = allowance))) + candidates = candidates[chosen_positions] + elseif allowance == 0 + empty!(candidates) + end + return sort!(vcat(collect(mandatory), candidates)) +end + +function downsample_series(series::PlotSeries, target::Integer) + indices = event_preserving_indices(series.x, series.y, target; events = series.event_indices) + remap = Dict(original => new for (new, original) in enumerate(indices)) + events = sort!([remap[index] for index in series.event_indices if haskey(remap, index)]) + lower = isnothing(series.uncertainty_lower) ? nothing : series.uncertainty_lower[indices] + upper = isnothing(series.uncertainty_upper) ? nothing : series.uncertainty_upper[indices] + return PlotSeries( + series.id, + series.label, + series.x[indices], + series.y[indices]; + x_unit = series.x_unit, + y_unit = series.y_unit, + source_hash = series.source_hash, + event_indices = events, + uncertainty_lower = lower, + uncertainty_upper = upper, + style_key = series.style_key, + ) +end + +function _finite_bounds(values::AbstractVector{<:Real}) + finite_values = Float64[value for value in values if isfinite(value)] + isempty(finite_values) && return (0.0, 1.0) + minimum_value, maximum_value = extrema(finite_values) + if minimum_value == maximum_value + padding = iszero(minimum_value) ? 1.0 : abs(minimum_value) * 0.05 + return (minimum_value - padding, maximum_value + padding) + end + return (minimum_value, maximum_value) +end diff --git a/AIMORAReporting.jl/src/workflow.jl b/AIMORAReporting.jl/src/workflow.jl new file mode 100644 index 0000000..fe26470 --- /dev/null +++ b/AIMORAReporting.jl/src/workflow.jl @@ -0,0 +1,148 @@ +function _assert_mutable(report::ReportDocument) + report.state in (:frozen, :superseded, :withdrawn) && + throw(ArgumentError("report state $(report.state) is immutable")) + return nothing +end + +function submit_for_review!(report::ReportDocument) + _assert_mutable(report) + report.state == :draft || throw(ArgumentError("only a draft report can enter review")) + quality = qa_report(report) + ispassing(quality) || throw(ArgumentError("report cannot enter review while semantic QA errors remain")) + report.state = :review + return report +end + +function add_comment!( + report::ReportDocument, + component_id, + author, + text; + severity = :warning, + id = string(uuid4()), + created_at = DateTime(1970, 1, 1), +) + report.state in (:review, :approved) || throw(ArgumentError("review comments require review or approved state")) + component_ids = Set{String}((report.id,)) + for section in report.sections + push!(component_ids, section.id) + for block in section.blocks + push!(component_ids, getfield(block, :id)) + end + end + String(component_id) in component_ids || throw(ArgumentError("unknown report component: $component_id")) + push!( + report.comments, + ReviewComment( + String(id), + String(component_id), + String(author), + Symbol(severity), + String(text), + :open, + created_at isa DateTime ? created_at : DateTime(created_at), + nothing, + "", + ), + ) + report.state == :approved && (report.state = :review) + return last(report.comments) +end + +function resolve_comment!( + report::ReportDocument, + comment_id, + resolution; + resolved_at = DateTime(1970, 1, 1), +) + comment = findfirst(item -> item.id == String(comment_id), report.comments) + isnothing(comment) && throw(ArgumentError("unknown review comment: $comment_id")) + report.comments[comment].status == :resolved && return report.comments[comment] + report.comments[comment].status = :resolved + report.comments[comment].resolved_at = resolved_at isa DateTime ? resolved_at : DateTime(resolved_at) + report.comments[comment].resolution = String(resolution) + return report.comments[comment] +end + +function approve!( + report::ReportDocument, + actor; + role = :approver, + id = string(uuid4()), + timestamp = DateTime(1970, 1, 1), +) + report.state == :review || throw(ArgumentError("only a report in review can be approved")) + quality = qa_report(report) + ispassing(quality) || throw(ArgumentError("report cannot be approved while semantic QA errors remain")) + any(comment -> comment.status != :resolved && comment.severity in (:critical, :error, :fatal), report.comments) && + throw(ArgumentError("blocking review comments remain unresolved")) + approval = ApprovalRecord( + String(id), + Symbol(role), + String(actor), + :approve, + timestamp isa DateTime ? timestamp : DateTime(timestamp), + content_hash(report), + ) + push!(report.approvals, approval) + report.state = :approved + return approval +end + +function freeze!(report::ReportDocument) + report.state == :approved || throw(ArgumentError("only an approved report can be frozen")) + current_hash = content_hash(report) + any(approval -> approval.action == :approve && approval.report_hash == current_hash, report.approvals) || + throw(ArgumentError("the report has no approval bound to its current content")) + quality = qa_report(report) + ispassing(quality) || throw(ArgumentError("report cannot be frozen while semantic QA errors remain")) + report.frozen_hash = current_hash + report.state = :frozen + return current_hash +end + +verify_frozen(report::ReportDocument) = + report.state == :frozen && !isnothing(report.frozen_hash) && report.frozen_hash == content_hash(report) + +function revised_copy( + report::ReportDocument; + new_id = report.id, + revision = report.revision + 1, + revision_label = "R$(revision)", + issued_at = report.metadata.issued_at, +) + metadata = ReportMetadata( + title = report.metadata.title, + subtitle = report.metadata.subtitle, + report_number = report.metadata.report_number, + revision_label = revision_label, + confidentiality = report.metadata.confidentiality, + prepared_by = report.metadata.prepared_by, + checked_by = report.metadata.checked_by, + approved_by = String[], + organization = report.metadata.organization, + client = report.metadata.client, + issued_at = issued_at, + language = report.metadata.language, + tags = report.metadata.tags, + ) + return ReportDocument( + String(new_id), + metadata, + deepcopy(report.bindings), + deepcopy(report.sections); + revision = revision, + schema_version = report.schema_version, + dependencies = deepcopy(report.dependencies), + state = :draft, + supersedes = report.id, + ) +end + +function supersede!(report::ReportDocument, replacement::ReportDocument) + report.state == :frozen || throw(ArgumentError("only a frozen report can be superseded")) + replacement.supersedes == report.id || throw(ArgumentError("replacement does not identify the superseded report")) + report.state = :superseded + report.superseded_by = replacement.id + return report +end diff --git a/AIMORAReporting.jl/test/runtests.jl b/AIMORAReporting.jl/test/runtests.jl index 8648f40..32489e5 100644 --- a/AIMORAReporting.jl/test/runtests.jl +++ b/AIMORAReporting.jl/test/runtests.jl @@ -1,6 +1,195 @@ using Test +using Dates using AIMORAReporting -@testset "semantic reporting package boundary" begin - @test nameof(AIMORAReporting) === :AIMORAReporting +function sample_binding(; result_id = "result-emt-001", family = :emt, upstream = String[]) + payload = Dict("result_id" => result_id, "values" => [0.0, 1.0, 0.5]) + return ResultBinding( + project_id = "project-001", + project_revision = "project@42", + scenario_id = "scenario-energization", + study_id = "study-$result_id", + study_family = family, + result_id = result_id, + solver_revision = "solver@abc123", + payload_hash = hash_payload(payload), + upstream_hashes = upstream, + units_base = "SI and per unit", + assumptions = ["Accepted test assumption"], + validity_domain = ["Public deterministic fixture"], + ) +end + +function sample_metadata() + return ReportMetadata( + title = "AIMORA Reporting Test", + report_number = "TEST-001", + revision_label = "D1", + prepared_by = ["Test Engineer"], + issued_at = DateTime(2026, 8, 20), + ) +end + +function sample_report() + binding = sample_binding() + x = collect(range(0.0, 0.01; length = 101)) + y = 1 .- exp.(-500 .* x) .* cos.(4000 .* x) + series = PlotSeries( + "series-voltage", + "Voltage", + x, + y; + x_unit = "s", + y_unit = "pu", + source_hash = binding.payload_hash, + event_indices = [1, 51], + style_key = "solid", + ) + figure = FigureBlock( + "figure-voltage", + "Voltage transient from the bound result.", + "Voltage starts at zero and approaches a damped steady value.", + PlotSpec( + "plot-voltage", + "Voltage transient", + AxisSpec("Time"; unit = "s"), + AxisSpec("Voltage"; unit = "pu"), + [series], + ); + source_ids = [binding.result_id], + ) + table = ReportTable( + "table-summary", + "Summary values", + ["quantity", "value"], + [["peak_voltage", maximum(y)], ["samples", length(y)]]; + units = ["", "pu or count"], + source_ids = [binding.result_id], + ) + return build_report( + :emt, + sample_metadata(), + [binding], + Dict( + "report_id" => "report-test-001", + "summary" => "A deterministic reporting fixture.", + "inputs" => Dict("duration_s" => 0.01, "samples" => length(x)), + "method" => "Manufactured fixed-step trace.", + "tables" => [table], + "figures" => [figure], + "quality" => Dict("finite" => true, "events_preserved" => true), + "findings" => ["The report is generated from typed result data."], + "limitations" => ["This is a manufactured reporting fixture."], + ), + ) +end + +@testset "canonical result binding" begin + binding = sample_binding() + @test length(binding.payload_hash) == 64 + @test hash_payload(Dict("b" => 2, "a" => 1)) == hash_payload(Dict("a" => 1, "b" => 2)) + @test occursin("project-001", canonical_json(binding)) +end + +@testset "provider and semantic QA" begin + report = sample_report() + quality = qa_report(report; required_roles = required_section_roles(:emt)) + @test ispassing(quality) + @test isempty(filter(issue -> issue.severity in (:error, :fatal), quality.issues)) + @test :emt in available_providers() + @test_throws ArgumentError build_report(:power_flow, sample_metadata(), [sample_binding()], Dict()) +end + +@testset "event-preserving sampling" begin + x = collect(1.0:1000.0) + y = sin.(x ./ 20) + y[500] = 25.0 + indices = event_preserving_indices(x, y, 50; events = [500]) + @test 1 in indices + @test 1000 in indices + @test all(index -> index in indices, (499, 500, 501)) + @test 500 in indices + @test issorted(indices) +end + +@testset "QA refuses invalid numerical presentation" begin + report = sample_report() + bad = PlotSeries( + "bad-series", + "Bad", + [0.0, 1.0], + [1.0, NaN]; + x_unit = "s", + y_unit = "pu", + source_hash = report.bindings[1].payload_hash, + ) + bad_figure = FigureBlock( + "bad-figure", + "Bad figure", + "Contains a non-finite value.", + PlotSpec("bad-plot", "Bad", AxisSpec("x"; unit = "s"), AxisSpec("y"; unit = "pu", scale = :log10), [bad]), + ) + push!(report.sections[4].blocks, bad_figure) + quality = qa_report(report) + @test !ispassing(quality) + @test :nonfinite_value in getfield.(quality.issues, :code) + @test :invalid_log_domain in getfield.(quality.issues, :code) +end + +@testset "review, approval, freeze, and correction" begin + report = sample_report() + submit_for_review!(report) + comment = add_comment!(report, "summary", "Reviewer", "Clarify the summary"; severity = :warning) + resolve_comment!(report, comment.id, "Summary confirmed") + approval = approve!(report, "Approver"; timestamp = DateTime(2026, 8, 20)) + @test approval.report_hash == content_hash(report) + frozen_hash = freeze!(report) + @test verify_frozen(report) + @test frozen_hash == content_hash(report) + correction = revised_copy(report; new_id = "report-test-002", revision_label = "R2") + @test correction.state == :draft + @test correction.supersedes == report.id + supersede!(report, correction) + @test report.state == :superseded +end + +@testset "deterministic render bundle" begin + report = sample_report() + submit_for_review!(report) + approve!(report, "Approver"; timestamp = DateTime(2026, 8, 20)) + freeze!(report) + mktempdir() do first + mktempdir() do second + manifest_a = render_bundle(report, first; generated_at = DateTime(2026, 8, 20)) + manifest_b = render_bundle(report, second; generated_at = DateTime(2026, 8, 20)) + @test manifest_a.report_hash == manifest_b.report_hash + @test manifest_a.artifacts == manifest_b.artifacts + @test read(joinpath(first, "report.json"), String) == read(joinpath(second, "report.json"), String) + @test read(joinpath(first, "report.md"), String) == read(joinpath(second, "report.md"), String) + @test isfile(joinpath(first, "figures", "figure-voltage.svg")) + @test isfile(joinpath(first, "data", "table-summary.csv")) + @test isfile(joinpath(first, "manifest.toml")) + end + end +end + +@testset "template validation" begin + mktempdir() do directory + path = joinpath(directory, "template.toml") + write( + path, + """ + id = "engineering-standard" + version = "1.0.0" + licence = "PolyForm-Noncommercial-1.0.0" + family = "engineering" + supported_studies = ["emt", "line_constants"] + required_roles = ["executive-summary", "results", "provenance"] + output_profiles = ["markdown", "html", "tex"] + """, + ) + template = load_template(path; trusted = true) + @test isempty(validate_template(template)) + @test length(template_hash(template)) == 64 + end end diff --git a/RESTORATION_STATUS.md b/RESTORATION_STATUS.md new file mode 100644 index 0000000..5801cd9 --- /dev/null +++ b/RESTORATION_STATUS.md @@ -0,0 +1,40 @@ +# Documentation and reporting restoration status + +Branch: `agent/restore-documentation-reporting` + +This branch restores the actual AIMORA reporting implementation to the writable personal fork. It is intended as the source branch for a later pull request to `AIMORA-dev/AIMORAPlatform`. + +## Restored implementation + +`AIMORAReporting.jl` now includes: + +- immutable project/scenario/study/result bindings; +- canonical semantic serialization and SHA-256 content hashes; +- renderer-independent narrative, equation, key-value, table, plot, figure, and diagnostic blocks; +- typed units, axes, series, event indices, uncertainty, transforms, and accessibility metadata; +- deterministic event/extrema-preserving downsampling; +- report-provider registry for EMT, line constants, cable constants, transformer parameters, validation, and combined reports; +- dependency-DAG validation; +- semantic and visual QA; +- versioned template loading and validation; +- Markdown, sanitized HTML, text, canonical JSON, portable TeX, CSV, SVG, and TikZ rendering; +- optional explicit Tectonic PDF boundary; +- review comments, approval hashes, publication freeze, revision, correction, and supersession; +- deterministic publication manifests and authored-file protection; +- package tests, structural checks, and a complete public reporting example. + +## Verification + +The branch contains `.github/workflows/restore-reporting-verification.yml`. It runs Julia 1.10 package checks, the test suite, the complete example twice, byte-comparison of both output bundles, and `git diff --check`. A successful run writes `AIMORAReporting.jl/RESTORE_VERIFICATION.md`. + +This restoration does not claim organization-level acceptance, scientific qualification, or ledger promotion. Those occur only after upstream review and the AIMORA workspace qualification process. + +## Upstream pull request + +Target repository: `AIMORA-dev/AIMORAPlatform` + +Target branch: `main` + +Head repository: `ahmelkholy/AIMORAPlatform` + +Head branch: `agent/restore-documentation-reporting`