From 2be4be70d5b98b06a928bfbac13adb8d19e3969d Mon Sep 17 00:00:00 2001 From: PraneethMerugu Date: Mon, 14 Sep 2026 17:36:50 -0400 Subject: [PATCH] Admit computed ordered-fold validation --- RELEASE_NOTES.md | 2 + docs/src/api/localmath.md | 8 + spec/localmath.md | 7 + src/execution/ordered_fold_stage.jl | 9 +- src/execution/stage_preparation.jl | 9 +- src/execution/stage_program.jl | 1 + src/ordered_fold.jl | 33 +++- .../ordered_fold_step_validation_contracts.jl | 151 ++++++++++++++++++ test/metal/ordered_fold_step_validation.jl | 4 + test/metal/runtests.jl | 1 + test/runtests.jl | 1 + test/test_ordered_fold_stage_model.jl | 13 ++ test/test_ordered_fold_step_validation.jl | 3 + 13 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/ordered_fold_step_validation_contracts.jl create mode 100644 test/metal/ordered_fold_step_validation.jl create mode 100644 test/test_ordered_fold_step_validation.jl diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7fab50a..0cdf834 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,6 +6,8 @@ `LocalMath.geometric_mean` methods for bounded relation and Collection views. - Gives all bounded reductions explicit result-type, empty-input, absence, and canonical-order semantics on the existing transaction-aware executor. +- Adds a device-safe computed-validity flag and bounded diagnostic witness to + `FoldStep`, preserving atomic ordered-state publication on semantic failure. # LocalMath 0.2.0-rc1 diff --git a/docs/src/api/localmath.md b/docs/src/api/localmath.md index c1dc8d8..4e3ce6e 100644 --- a/docs/src/api/localmath.md +++ b/docs/src/api/localmath.md @@ -114,6 +114,14 @@ destination. This holds for parameter gates and gates produced by a preceding total Field publication. Opening the gate restores ordinary ordered-fold validation, including rejection of duplicate ordering identities. +An ordered transition returns `FoldStep(updates; valid, witness, halt)`. A +computed `valid=false` result rejects before that step changes private +accumulator scratch and reports its `Int32` witness together with the source +item and canonical position. The stage publishes no accumulator component when +any step is invalid. Ordinary scientific denial is not a validation failure: +return a valid step that records the denied disposition and omits the denied +state change. + ## Bounded scalar operators `LocalMath.fold` names the mathematical action and takes an already bounded diff --git a/spec/localmath.md b/spec/localmath.md index fc112a5..917d24c 100644 --- a/spec/localmath.md +++ b/spec/localmath.md @@ -97,6 +97,13 @@ Execution evolves ordered state in package-owned workspace and commits it only after final validation succeeds, preserving stage-entry destinations on any late recurrence failure. +The explicit transition API may return +`FoldStep(updates; valid=false, witness=Int32(...))` when its computed result +violates a domain-owned invariant. The executor reports that bounded witness +with the event's source item and canonical position before applying the step's +updates. A normal denied event remains a valid step: it records the denial in +state and emits zero writes for changes that were not realized. + ## Binding and storage ownership Ordinary preparation accepts one flat sequence of descriptor-to-storage pairs. diff --git a/src/execution/ordered_fold_stage.jl b/src/execution/ordered_fold_stage.jl index de593f7..6605551 100644 --- a/src/execution/ordered_fold_stage.jl +++ b/src/execution/ordered_fold_stage.jl @@ -260,7 +260,14 @@ end end ) end - return Expr(:block, checks..., :((Int32(0), Int32(0), Int32(0)))) + return Expr( + :block, + :(!step.valid && return ( + Int32(_ORDERED_FOLD_INVALID_STEP), Int32(0), step.witness, + )), + checks..., + :((Int32(0), Int32(0), Int32(0))), + ) end @inline function _ordered_fold_stage_apply_writes!(storage, writes::BoundedWrites) for j in Int32(1):writes.count diff --git a/src/execution/stage_preparation.jl b/src/execution/stage_preparation.jl index 394471c..83e4e0d 100644 --- a/src/execution/stage_preparation.jl +++ b/src/execution/stage_preparation.jl @@ -237,8 +237,13 @@ Base.eltype(::Type{_PreparedFoldRead{T}}) where {T} = T Base.length(view::_PreparedFoldRead) = length(getfield(view, :storage)) Base.size(view::_PreparedFoldRead) = size(getfield(view, :storage)) Base.axes(view::_PreparedFoldRead) = axes(getfield(view, :storage)) -@inline Base.getindex(view::_PreparedFoldRead, indices...) = - @inbounds getfield(view, :storage)[indices...] +# This call boundary preserves evolving-state loads across writes to the same +# ordered-fold scratch. Without it Metal hoists constant-index loads out of the +# recurrence and later canonical steps observe the initializer snapshot. +Base.@noinline Base.getindex(view::_PreparedFoldRead, index::Int) = + @inbounds getfield(view, :storage)[index] +Base.@noinline Base.getindex(view::_PreparedFoldRead, index::Int32) = + @inbounds getfield(view, :storage)[index] struct _PreparedFoldAccumulatorView{Names,C<:NamedTuple} components::C diff --git a/src/execution/stage_program.jl b/src/execution/stage_program.jl index 8907023..4183b9f 100644 --- a/src/execution/stage_program.jl +++ b/src/execution/stage_program.jl @@ -1053,6 +1053,7 @@ function _validated_publication_error( code == _ORDERED_FOLD_DUPLICATE_ORDER ? :duplicate_order_identity : code == _ORDERED_FOLD_INVALID_VALUE ? :invalid_bounded_value : code == _ORDERED_FOLD_EMPTY_INPUT ? :empty_bounded_input : + code == _ORDERED_FOLD_INVALID_STEP ? :invalid_step : :invalid_failure_class component = publication !== nothing && hasproperty(publication.details, :fold_components) && diff --git a/src/ordered_fold.jl b/src/ordered_fold.jl index 6b8e5c5..1bcba95 100644 --- a/src/ordered_fold.jl +++ b/src/ordered_fold.jl @@ -6,7 +6,13 @@ struct _FoldInPlace end const _ORDERED_FOLD_MAX_EXTENT = Int(typemax(Int32)) - 1 const _ORDERED_FOLD_MAX_COMPONENT_UPDATES = 32 const _ORDERED_FOLD_MAX_TOTAL_UPDATES = 128 -const _ORDERED_FOLD_MAX_STEP_BYTES = 1024 +# The update-plus-halt envelope remains 1024 bytes; validity and its Int32 +# witness add at most eight bytes including alignment to an admitted layout. +const _ORDERED_FOLD_MAX_UPDATE_AND_HALT_BYTES = 1024 +const _ORDERED_FOLD_VALIDITY_LAYOUT_BYTES = 8 +const _ORDERED_FOLD_MAX_STEP_BYTES = + _ORDERED_FOLD_MAX_UPDATE_AND_HALT_BYTES + + _ORDERED_FOLD_VALIDITY_LAYOUT_BYTES """ FoldComponent(target::Field; from=nothing, in_place=false) @@ -129,16 +135,24 @@ BoundedWrites(::Type{T}) where {T} = BoundedWrites{0, T}((), (), Int32(0)) """ - FoldStep(updates; halt=false) + FoldStep(updates; valid=true, witness=Int32(0), halt=false) Return one kernel-local ordered-fold step. `updates` is a named tuple whose values are [`BoundedWrites`](@ref). Its names are checked against the declared accumulator schema during planning; the executor applies components in schema order rather than caller tuple order. `halt=true` stops only the later canonical prefix and does not imply rollback. + +`valid=false` rejects a transition result before any of its updates reach the +private accumulator scratch. `witness` is an `Int32` diagnostic value reported +with the existing source-item and canonical-position context. A scientifically +valid denial should instead return an ordinary valid step whose updates publish +the denied disposition. """ struct FoldStep{Names, U <: NamedTuple} updates::U + valid::Bool + witness::Int32 halt::Bool end @@ -148,12 +162,14 @@ _fold_writes_tuple(updates::Tuple) = function FoldStep( updates::U; + valid::Bool = true, + witness::Int32 = Int32(0), halt::Bool = false, ) where {U <: NamedTuple} _fold_writes_tuple(values(updates)) || throw(ArgumentError( "FoldStep updates must be a named tuple of BoundedWrites values" )) - return FoldStep{keys(updates), U}(updates, halt) + return FoldStep{keys(updates), U}(updates, valid, witness, halt) end """Validate the shared static ABI for a Stage ordered-recurrence transition. @@ -192,6 +208,16 @@ function _validate_ordered_fold_step_type( stage, contract = Symbol(prefix, :_halt_type), expected = Bool, actual = fieldtype(step_type, :halt), )) + fieldtype(step_type, :valid) === Bool || throw(LocalMathValidationError( + "ordered-fold transition validity must be Bool"; + stage, contract = Symbol(prefix, :_valid_type), + expected = Bool, actual = fieldtype(step_type, :valid), + )) + fieldtype(step_type, :witness) === Int32 || throw(LocalMathValidationError( + "ordered-fold transition witness must be Int32"; + stage, contract = Symbol(prefix, :_witness_type), + expected = Int32, actual = fieldtype(step_type, :witness), + )) update_names = step_type.parameters[1] Set(update_names) == Set(names) || throw(LocalMathValidationError( "ordered-fold step updates must name every accumulator component"; @@ -246,3 +272,4 @@ const _ORDERED_FOLD_DUPLICATE_UPDATE = UInt8(3) const _ORDERED_FOLD_DUPLICATE_ORDER = UInt8(4) const _ORDERED_FOLD_INVALID_VALUE = UInt8(5) const _ORDERED_FOLD_EMPTY_INPUT = UInt8(6) +const _ORDERED_FOLD_INVALID_STEP = UInt8(7) diff --git a/test/fixtures/ordered_fold_step_validation_contracts.jl b/test/fixtures/ordered_fold_step_validation_contracts.jl new file mode 100644 index 0000000..637642e --- /dev/null +++ b/test/fixtures/ordered_fold_step_validation_contracts.jl @@ -0,0 +1,151 @@ +using Test +import KernelAbstractions +import LocalMath + +struct OrderedFoldStepValidityDomain end +struct OrderedFoldStepValidityEvaluator end + +@inline function (::OrderedFoldStepValidityEvaluator)( + item::Int32, reads, parameters) + imbalance = item == Int32(3) ? getfield(parameters, 1) : Int32(0) + return (event = LocalMath.FoldValue((item, imbalance)),) +end + +struct OrderedFoldStepValidityTransition end + +@inline function (::OrderedFoldStepValidityTransition)( + state, value, item, reads, + ) + event = value[1] + requested = event == Int32(1) ? Int32(3) : + event == Int32(2) ? Int32(8) : Int32(5) + available = state.available[Int32(1)] + realized = requested <= available ? requested : Int32(0) + update_count = realized == Int32(0) ? Int32(0) : Int32(1) + disposition = realized == Int32(0) ? UInt8(0) : UInt8(1) + imbalance = value[2] + available_next = available - realized + received_next = state.received[Int32(1)] + realized + imbalance + balance = available_next + received_next + valid = balance == Int32(10) + witness = balance - Int32(10) + # The invalid result deliberately also proposes an out-of-range write. The + # semantic invalidity must win before structural write validation or apply. + destination = valid ? Int32(1) : Int32(2) + disposition_destination = valid ? event : Int32(4) + return LocalMath.FoldStep(( + available = LocalMath.BoundedWrites( + (destination,), (available_next,), update_count), + received = LocalMath.BoundedWrites( + (Int32(1),), (received_next,), update_count), + disposition = LocalMath.BoundedWrites( + (disposition_destination,), (disposition,), Int32(1)), + ); valid, witness) +end + +function _ordered_fold_step_validity_prepared(array_type) + events = LocalMath.Space(OrderedFoldStepValidityDomain, 3) + resource = LocalMath.Space(OrderedFoldStepValidityDomain, 1) + available_initial = LocalMath.Field(resource, Int32) + available = LocalMath.Field(resource, Int32) + received_initial = LocalMath.Field(resource, Int32) + received = LocalMath.Field(resource, Int32) + disposition_initial = LocalMath.Field(events, UInt8) + disposition = LocalMath.Field(events, UInt8) + imbalance = LocalMath.Parameter(:imbalance, Int32) + state = LocalMath.initialized_state(; + available = LocalMath.FoldComponent( + available; from = available_initial), + received = LocalMath.FoldComponent( + received; from = received_initial), + disposition = LocalMath.FoldComponent( + disposition; from = disposition_initial), + ) + publication = LocalMath.Publication((LocalMath.FoldPublication( + LocalMath.PublicationValue(:event)),), LocalMath.OrderedFold( + NTuple{2,Int32}, state, OrderedFoldStepValidityTransition(), + )) + stage = LocalMath.Stage( + events, NamedTuple(), (publication,), + LocalMath.Evaluator(OrderedFoldStepValidityEvaluator(), (imbalance,)), + LocalMath.Control(), + LocalMath.SourceOrigin( + @__FILE__, @__LINE__; label = :ordered_fold_step_validity), + ) + available_output = array_type(Int32[-41]) + received_output = array_type(Int32[-42]) + disposition_output = array_type(fill(UInt8(0xff), 3)) + work = LocalMath.LocalLaw(stage; + parameters = LocalMath.ParameterSchema(imbalance)) + prepared = LocalMath.prepare( + work, + available_initial => array_type(Int32[10]), + available => available_output, + received_initial => array_type(Int32[0]), + received => received_output, + disposition_initial => array_type(zeros(UInt8, 3)), + disposition => disposition_output; + backend = KernelAbstractions.get_backend(available_output), + ) + return (; + prepared, available_output, received_output, disposition_output, + ) +end + +function _ordered_fold_step_validity_execute(prepared, imbalance::Int32) + return try + wait(LocalMath.execute!(prepared; parameters = (; imbalance))) + nothing + catch error + error + end +end + +function ordered_fold_step_validation_contracts(array_type) + return @testset "computed ordered-fold step validity is transactional" begin + transition = OrderedFoldStepValidityTransition() + state = (available = Int32[7], received = Int32[3], + disposition = zeros(UInt8, 3)) + valid_step = transition( + state, (Int32(3), Int32(0)), Int32(3), ()) + invalid_step = transition( + state, (Int32(3), Int32(1)), Int32(3), ()) + @test typeof(valid_step) === typeof(invalid_step) + + witness = _ordered_fold_step_validity_prepared(array_type) + accepted = _ordered_fold_step_validity_execute( + witness.prepared, Int32(0)) + @test accepted === nothing + @test ( + available = Array(witness.available_output), + received = Array(witness.received_output), + disposition = Array(witness.disposition_output), + ) == ( + available = Int32[2], + received = Int32[8], + disposition = UInt8[1, 0, 1], + ) + + copyto!(witness.available_output, array_type(Int32[-41])) + copyto!(witness.received_output, array_type(Int32[-42])) + copyto!(witness.disposition_output, array_type(fill(UInt8(0xff), 3))) + rejected = _ordered_fold_step_validity_execute( + witness.prepared, Int32(1)) + @test rejected isa LocalMath.LocalMathValidationError + @test rejected.contract === :runtime_ordered_fold_validation + @test rejected.actual.failure_class === :invalid_step + @test rejected.actual.component === nothing + @test rejected.actual.source_item == Int32(3) + @test rejected.actual.canonical_position == Int32(3) + @test rejected.actual.witness == Int32(1) + @test ( + available = Array(witness.available_output), + received = Array(witness.received_output), + disposition = Array(witness.disposition_output), + ) == ( + available = Int32[-41], + received = Int32[-42], + disposition = fill(UInt8(0xff), 3), + ) + end +end diff --git a/test/metal/ordered_fold_step_validation.jl b/test/metal/ordered_fold_step_validation.jl new file mode 100644 index 0000000..33a67e7 --- /dev/null +++ b/test/metal/ordered_fold_step_validation.jl @@ -0,0 +1,4 @@ +include(joinpath(@__DIR__, "..", "fixtures", + "ordered_fold_step_validation_contracts.jl")) + +ordered_fold_step_validation_contracts(Metal.MtlArray) diff --git a/test/metal/runtests.jl b/test/metal/runtests.jl index ece18ab..4b11292 100644 --- a/test/metal/runtests.jl +++ b/test/metal/runtests.jl @@ -15,6 +15,7 @@ const LOCALMATH_METAL_WITNESSES = ( "destination_grouping.jl", "product_values.jl", "ordered_fold_control.jl", + "ordered_fold_step_validation.jl", "reduction_control.jl", "empty_pointwise_domains.jl", "collect_canonical_order.jl", diff --git a/test/runtests.jl b/test/runtests.jl index 7809a9b..8f46fd8 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -26,6 +26,7 @@ const LOCALMATH_INCLUDED_TESTS = ( "test_collect_canonical_order.jl", "test_ordered_fold_stage_model.jl", "test_ordered_fold_stage_execution.jl", + "test_ordered_fold_step_validation.jl", "test_ordered_fold_control.jl", "test_stage_failure_barrier.jl", "test_stage_collection_binding.jl", diff --git a/test/test_ordered_fold_stage_model.jl b/test/test_ordered_fold_stage_model.jl index 45ec525..6ee55a7 100644 --- a/test/test_ordered_fold_stage_model.jl +++ b/test/test_ordered_fold_stage_model.jl @@ -12,6 +12,19 @@ struct OrderedFoldStageTransition end (Int32(1),), (value,), Int32(1)),)) end +@testset "FoldStep validity metadata preserves bounded update capacity" begin + value = ntuple(_ -> Int64(0), 10) + writes = LMF.BoundedWrites( + ntuple(Int32, 12), ntuple(_ -> value, 12), Int32(12)) + updates = (accumulator = writes,) + step = LMF.FoldStep(updates) + + @test sizeof(Tuple{typeof(updates),Bool}) == 1024 + @test sizeof(step) == 1032 + @test LMF._validate_ordered_fold_step_type( + typeof(step), (:accumulator,), (NTuple{10,Int64},)) === typeof(step) +end + @testset "OrderedFold is a terminal typed recurrence Stage law" begin source = LMF.Space(OrderedFoldStageModelNode, 5) state_space = LMF.Space(OrderedFoldStageModelNode, 2) diff --git a/test/test_ordered_fold_step_validation.jl b/test/test_ordered_fold_step_validation.jl new file mode 100644 index 0000000..625e162 --- /dev/null +++ b/test/test_ordered_fold_step_validation.jl @@ -0,0 +1,3 @@ +include("fixtures/ordered_fold_step_validation_contracts.jl") + +ordered_fold_step_validation_contracts(Array)