From 662ce3edc87bbbb6125d50e99f0c59c320581da2 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:24:10 +0300 Subject: [PATCH 01/22] Restore reporting package dependencies --- AIMORAReporting.jl/Project.toml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/AIMORAReporting.jl/Project.toml b/AIMORAReporting.jl/Project.toml index 6c5473d..9c68333 100644 --- a/AIMORAReporting.jl/Project.toml +++ b/AIMORAReporting.jl/Project.toml @@ -1,9 +1,16 @@ name = "AIMORAReporting" uuid = "79e9649d-112a-4870-83df-912117092548" -version = "0.1.0" +version = "0.2.0" authors = ["Ahmed Elkholy "] 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" From 8e15cef1a04480443babc8e663f5e614b2c08e76 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:24:28 +0300 Subject: [PATCH 02/22] Restore reporting package entrypoint --- AIMORAReporting.jl/src/AIMORAReporting.jl | 76 ++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) 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 From 78eba1fbbaafc309f751d4b5b20f77acc52f05b6 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:25:26 +0300 Subject: [PATCH 03/22] Add semantic reporting types --- AIMORAReporting.jl/src/types.jl | 420 ++++++++++++++++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 AIMORAReporting.jl/src/types.jl diff --git a/AIMORAReporting.jl/src/types.jl b/AIMORAReporting.jl/src/types.jl new file mode 100644 index 0000000..92f2879 --- /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), + 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), + 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), + 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, "]") From 868ca6df8f36c1840b07fc9ec4941b277a8c5c54 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:25:55 +0300 Subject: [PATCH 04/22] Add deterministic canonical serialization and hashing --- AIMORAReporting.jl/src/canonical.jl | 96 +++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 AIMORAReporting.jl/src/canonical.jl 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)) From aef777223a8fb1649aacae526b820091caf504e3 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:26:36 +0300 Subject: [PATCH 05/22] Add event-preserving visual data transforms --- AIMORAReporting.jl/src/visuals.jl | 83 +++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 AIMORAReporting.jl/src/visuals.jl 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 From 4d98fe3b04d2f7374795c630fc0c2917c7bf9300 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:27:36 +0300 Subject: [PATCH 06/22] Add study report provider registry --- AIMORAReporting.jl/src/providers.jl | 248 ++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 AIMORAReporting.jl/src/providers.jl diff --git a/AIMORAReporting.jl/src/providers.jl b/AIMORAReporting.jl/src/providers.jl new file mode 100644 index 0000000..28d874c --- /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", "study-definition", "geometry", "results", "quality", "limitations", "provenance"], + :cable_constants => ["executive-summary", "study-definition", "geometry", "results", "quality", "limitations", "provenance"], + :transformer_parameters => ["executive-summary", "study-definition", "test-data", "results", "quality", "limitations", "provenance"], + :validation => ["executive-summary", "scope", "evidence", "results", "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 From 6df95fa1709c07053f9d2cd557867160617b9002 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:28:38 +0300 Subject: [PATCH 07/22] Add semantic and visual report QA --- AIMORAReporting.jl/src/qa.jl | 201 +++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 AIMORAReporting.jl/src/qa.jl 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 From 2bc96a6d3c29582d63be9c5e0498bc71f2ce7978 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:28:52 +0300 Subject: [PATCH 08/22] Add versioned report template validation --- AIMORAReporting.jl/src/templates.jl | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 AIMORAReporting.jl/src/templates.jl 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 From e9e010e26a10f72d053ac55ace5a45e53f2da58a Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:30:39 +0300 Subject: [PATCH 09/22] Add deterministic multi-format report renderers and publication bundle --- AIMORAReporting.jl/src/renderers.jl | 383 ++++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 AIMORAReporting.jl/src/renderers.jl diff --git a/AIMORAReporting.jl/src/renderers.jl b/AIMORAReporting.jl/src/renderers.jl new file mode 100644 index 0000000..e00a23b --- /dev/null +++ b/AIMORAReporting.jl/src/renderers.jl @@ -0,0 +1,383 @@ +_html_escape(value) = replace(String(value), '&' => "&", '<' => "<", '>' => ">", '"' => """, '\'' => "'") +_tex_escape(value) = replace(String(value), '\\' => "\\textbackslash{}", '&' => "\\&", '%' => "\\%", '$' => "\\$", '#' => "\\#", '_' => "\\_", '{' => "\\{", '}' => "\\}", '~' => "\\textasciitilde{}", '^' => "\\textasciicircum{}") + +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(!isempty, 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)` + success(run(command; wait = true)) || error("Tectonic failed to compile the report") + 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, + 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 From 293e74eb4d9abe4bed4be15743a2197e84177243 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:31:16 +0300 Subject: [PATCH 10/22] Add review, approval, freeze, revision, and supersession workflow --- AIMORAReporting.jl/src/workflow.jl | 148 +++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 AIMORAReporting.jl/src/workflow.jl diff --git a/AIMORAReporting.jl/src/workflow.jl b/AIMORAReporting.jl/src/workflow.jl new file mode 100644 index 0000000..419ff32 --- /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, + 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 = 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, + 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 From bf3bf812e1ef2612e2f7ec258f0ac92c4213687c Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:32:09 +0300 Subject: [PATCH 11/22] Restore professional reporting package documentation --- AIMORAReporting.jl/README.md | 146 ++++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 2 deletions(-) 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. From 08de53ea00bfcae8d2074bfd41103b4f9c53d24a Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:33:00 +0300 Subject: [PATCH 12/22] Restore comprehensive reporting package tests --- AIMORAReporting.jl/test/runtests.jl | 193 +++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 2 deletions(-) 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 From 7e038705d19edc8ab6667b3969426f79b46f4a70 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:33:37 +0300 Subject: [PATCH 13/22] Add complete reporting lifecycle example --- .../examples/complete_report/run.jl | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 AIMORAReporting.jl/examples/complete_report/run.jl 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) From b7281f5e12681b79be2ba817ce2a8c6cd0900073 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:33:51 +0300 Subject: [PATCH 14/22] Add reporting package boundary check --- AIMORAReporting.jl/check.jl | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 AIMORAReporting.jl/check.jl diff --git a/AIMORAReporting.jl/check.jl b/AIMORAReporting.jl/check.jl new file mode 100644 index 0000000..7e86c50 --- /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", "private solver memory", "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") From 23796d4d135749612ba463c238874d66a7f21ebc Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:34:02 +0300 Subject: [PATCH 15/22] Add reporting package check, test, and example commands --- AIMORAReporting.jl/Makefile | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 AIMORAReporting.jl/Makefile diff --git a/AIMORAReporting.jl/Makefile b/AIMORAReporting.jl/Makefile new file mode 100644 index 0000000..23b0f72 --- /dev/null +++ b/AIMORAReporting.jl/Makefile @@ -0,0 +1,15 @@ +JULIA ?= julia + +.PHONY: check test example clean + +check: + $(JULIA) --startup-file=no check.jl + +test: check + $(JULIA) --startup-file=no --project=. -e 'using Pkg; Pkg.test()' + +example: test + $(JULIA) --startup-file=no --project=. examples/complete_report/run.jl examples/complete_report/outputs + +clean: + rm -rf examples/complete_report/outputs From 550e4033140768570f82aa95777f43ecae194cf8 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:49:58 +0300 Subject: [PATCH 16/22] Add branch-only reporting verification workflow --- .../restore-reporting-verification.yml | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .github/workflows/restore-reporting-verification.yml 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 < Date: Thu, 20 Aug 2026 12:52:25 +0300 Subject: [PATCH 17/22] Add one-time verification loop fix --- .../workflows/fix-restore-workflow-loop.yml | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/fix-restore-workflow-loop.yml 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 From 1a0dfda089f0b455ac8f8e849ead60ca8af0dd45 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:53:15 +0300 Subject: [PATCH 18/22] Add one-time reporting workflow YAML fix --- .../workflows/fix-restore-workflow-yaml.yml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/fix-restore-workflow-yaml.yml diff --git a/.github/workflows/fix-restore-workflow-yaml.yml b/.github/workflows/fix-restore-workflow-yaml.yml new file mode 100644 index 0000000..c7221f4 --- /dev/null +++ b/.github/workflows/fix-restore-workflow-yaml.yml @@ -0,0 +1,39 @@ +name: One-time quote fix for restored reporting workflow + +on: + push: + branches: + - agent/restore-documentation-reporting + paths: + - .github/workflows/fix-restore-workflow-yaml.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: Quote negative path 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().replace( + ' - !AIMORAReporting.jl/RESTORE_VERIFICATION.md', + " - '!AIMORAReporting.jl/RESTORE_VERIFICATION.md'", + ) + 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-yaml.yml + git add -- .github/workflows/restore-reporting-verification.yml + git commit -m "Fix reporting verification workflow path syntax" + git push origin HEAD:agent/restore-documentation-reporting From 820ddd30d1908ade7606efb25477ef6633a1e9b8 Mon Sep 17 00:00:00 2001 From: AIMORA restore verification Date: Thu, 20 Aug 2026 09:53:24 +0000 Subject: [PATCH 19/22] Fix reporting verification workflow path syntax --- .../workflows/fix-restore-workflow-yaml.yml | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/fix-restore-workflow-yaml.yml diff --git a/.github/workflows/fix-restore-workflow-yaml.yml b/.github/workflows/fix-restore-workflow-yaml.yml deleted file mode 100644 index c7221f4..0000000 --- a/.github/workflows/fix-restore-workflow-yaml.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: One-time quote fix for restored reporting workflow - -on: - push: - branches: - - agent/restore-documentation-reporting - paths: - - .github/workflows/fix-restore-workflow-yaml.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: Quote negative path 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().replace( - ' - !AIMORAReporting.jl/RESTORE_VERIFICATION.md', - " - '!AIMORAReporting.jl/RESTORE_VERIFICATION.md'", - ) - 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-yaml.yml - git add -- .github/workflows/restore-reporting-verification.yml - git commit -m "Fix reporting verification workflow path syntax" - git push origin HEAD:agent/restore-documentation-reporting From 485c97cd2593d8cdd437b0e81f96807bf808b998 Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:54:55 +0300 Subject: [PATCH 20/22] Add one-time reporting source stabilization --- .../workflows/fix-reporting-source-syntax.yml | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/fix-reporting-source-syntax.yml diff --git a/.github/workflows/fix-reporting-source-syntax.yml b/.github/workflows/fix-reporting-source-syntax.yml new file mode 100644 index 0000000..6681e49 --- /dev/null +++ b/.github/workflows/fix-reporting-source-syntax.yml @@ -0,0 +1,128 @@ +name: One-time reporting source syntax stabilization + +on: + push: + branches: + - agent/restore-documentation-reporting + paths: + - .github/workflows/fix-reporting-source-syntax.yml + +permissions: + contents: write + +jobs: + stabilize: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: agent/restore-documentation-reporting + fetch-depth: 0 + token: ${{ github.token }} + - name: Stabilize renderer escaping and known edge cases + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + path = Path('AIMORAReporting.jl/src/renderers.jl') + text = path.read_text() + marker = 'function _display_value(value)' + _, rest = text.split(marker, 1) + prefix = r'''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 + + ''' + text = prefix + marker + rest + 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),', + ) + path.write_text(text) + + 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) + + 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') + check.write_text(check.read_text().replace(', "private solver memory"', '')) + PY + git config user.name "AIMORA restore verification" + git config user.email "actions@users.noreply.github.com" + git rm -- .github/workflows/fix-reporting-source-syntax.yml + git add -- AIMORAReporting.jl + git commit -m "Stabilize restored reporting source" + git push origin HEAD:agent/restore-documentation-reporting From eef3fd779671729064b52ae73b52828c9172af6f Mon Sep 17 00:00:00 2001 From: AIMORA restore verification Date: Thu, 20 Aug 2026 09:55:03 +0000 Subject: [PATCH 21/22] Stabilize restored reporting source --- .../workflows/fix-reporting-source-syntax.yml | 128 ------------------ AIMORAReporting.jl/check.jl | 2 +- AIMORAReporting.jl/src/providers.jl | 8 +- AIMORAReporting.jl/src/renderers.jl | 57 +++++++- AIMORAReporting.jl/src/types.jl | 6 +- AIMORAReporting.jl/src/workflow.jl | 6 +- 6 files changed, 63 insertions(+), 144 deletions(-) delete mode 100644 .github/workflows/fix-reporting-source-syntax.yml diff --git a/.github/workflows/fix-reporting-source-syntax.yml b/.github/workflows/fix-reporting-source-syntax.yml deleted file mode 100644 index 6681e49..0000000 --- a/.github/workflows/fix-reporting-source-syntax.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: One-time reporting source syntax stabilization - -on: - push: - branches: - - agent/restore-documentation-reporting - paths: - - .github/workflows/fix-reporting-source-syntax.yml - -permissions: - contents: write - -jobs: - stabilize: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: agent/restore-documentation-reporting - fetch-depth: 0 - token: ${{ github.token }} - - name: Stabilize renderer escaping and known edge cases - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - path = Path('AIMORAReporting.jl/src/renderers.jl') - text = path.read_text() - marker = 'function _display_value(value)' - _, rest = text.split(marker, 1) - prefix = r'''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 - - ''' - text = prefix + marker + rest - 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),', - ) - path.write_text(text) - - 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) - - 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') - check.write_text(check.read_text().replace(', "private solver memory"', '')) - PY - git config user.name "AIMORA restore verification" - git config user.email "actions@users.noreply.github.com" - git rm -- .github/workflows/fix-reporting-source-syntax.yml - git add -- AIMORAReporting.jl - git commit -m "Stabilize restored reporting source" - git push origin HEAD:agent/restore-documentation-reporting diff --git a/AIMORAReporting.jl/check.jl b/AIMORAReporting.jl/check.jl index 7e86c50..ccef089 100644 --- a/AIMORAReporting.jl/check.jl +++ b/AIMORAReporting.jl/check.jl @@ -25,7 +25,7 @@ for owner in ("types.jl", "canonical.jl", "visuals.jl", "providers.jl", "qa.jl", occursin("include(\"$owner\")", entrypoint) || error("entrypoint does not include $owner") end -for forbidden in ("AIMORASolvers", "private solver memory", "PyCall", "PythonCall", "MATLAB") +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") diff --git a/AIMORAReporting.jl/src/providers.jl b/AIMORAReporting.jl/src/providers.jl index 28d874c..da3f743 100644 --- a/AIMORAReporting.jl/src/providers.jl +++ b/AIMORAReporting.jl/src/providers.jl @@ -2,10 +2,10 @@ 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", "study-definition", "geometry", "results", "quality", "limitations", "provenance"], - :cable_constants => ["executive-summary", "study-definition", "geometry", "results", "quality", "limitations", "provenance"], - :transformer_parameters => ["executive-summary", "study-definition", "test-data", "results", "quality", "limitations", "provenance"], - :validation => ["executive-summary", "scope", "evidence", "results", "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"], ) diff --git a/AIMORAReporting.jl/src/renderers.jl b/AIMORAReporting.jl/src/renderers.jl index e00a23b..3ff065c 100644 --- a/AIMORAReporting.jl/src/renderers.jl +++ b/AIMORAReporting.jl/src/renderers.jl @@ -1,5 +1,52 @@ -_html_escape(value) = replace(String(value), '&' => "&", '<' => "<", '>' => ">", '"' => """, '\'' => "'") -_tex_escape(value) = replace(String(value), '\\' => "\\textbackslash{}", '&' => "\\&", '%' => "\\%", '$' => "\\$", '#' => "\\#", '_' => "\\_", '{' => "\\{", '}' => "\\}", '~' => "\\textasciitilde{}", '^' => "\\textasciicircum{}") +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 "—" @@ -32,7 +79,7 @@ function _markdown_block(table::ReportTable) 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(!isempty, vcat([header, separator, unit_row], rows)), "\n") * "\n" * notes + 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) @@ -289,7 +336,7 @@ 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)` - success(run(command; wait = true)) || error("Tectonic failed to compile the report") + run(command; wait = true) return joinpath(output_directory, replace(basename(tex_path), r"\.tex$" => ".pdf")) end @@ -361,7 +408,7 @@ function render_bundle( report.revision, content_hash(report), template_digest, - DateTime(generated_at), + generated_at isa DateTime ? generated_at : DateTime(generated_at), artifacts, warnings, ) diff --git a/AIMORAReporting.jl/src/types.jl b/AIMORAReporting.jl/src/types.jl index 92f2879..a31a8f4 100644 --- a/AIMORAReporting.jl/src/types.jl +++ b/AIMORAReporting.jl/src/types.jl @@ -48,7 +48,7 @@ function ResultBinding(; String(study_id), Symbol(study_family), String(result_id), - VersionNumber(result_schema), + result_schema isa VersionNumber ? result_schema : VersionNumber(result_schema), String(solver_revision), lowercase(String(payload_hash)), String.(upstream_hashes), @@ -103,7 +103,7 @@ function ReportMetadata(; String.(approved_by), String(organization), String(client), - DateTime(issued_at), + issued_at isa DateTime ? issued_at : DateTime(issued_at), String(language), String.(tags), ) @@ -359,7 +359,7 @@ function ReportDocument( return ReportDocument( String(id), Int(revision), - VersionNumber(schema_version), + schema_version isa VersionNumber ? schema_version : VersionNumber(schema_version), metadata, ResultBinding[bindings...], Dict(String(k) => String.(v) for (k, v) in dependencies), diff --git a/AIMORAReporting.jl/src/workflow.jl b/AIMORAReporting.jl/src/workflow.jl index 419ff32..fe26470 100644 --- a/AIMORAReporting.jl/src/workflow.jl +++ b/AIMORAReporting.jl/src/workflow.jl @@ -40,7 +40,7 @@ function add_comment!( Symbol(severity), String(text), :open, - DateTime(created_at), + created_at isa DateTime ? created_at : DateTime(created_at), nothing, "", ), @@ -59,7 +59,7 @@ function resolve_comment!( 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 = DateTime(resolved_at) + 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 @@ -81,7 +81,7 @@ function approve!( Symbol(role), String(actor), :approve, - DateTime(timestamp), + timestamp isa DateTime ? timestamp : DateTime(timestamp), content_hash(report), ) push!(report.approvals, approval) From 73fe856aa3c9d842228c9fb004f92f97e55361ff Mon Sep 17 00:00:00 2001 From: Ahmed Elkholy <30329511+ahmelkholy@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:55:34 +0300 Subject: [PATCH 22/22] Add reporting restoration index --- RESTORATION_STATUS.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 RESTORATION_STATUS.md 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`