From 975d8a5cc97e36b9bdbccc597245bc94a24b2bc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Fri, 18 Sep 2026 17:04:37 +0200 Subject: [PATCH] Fix selector and output history regressions --- CHANGELOG.md | 14 ++- src/composite_model/compilation.jl | 117 +++++++++++++++-------- src/composite_model/runtime_outputs.jl | 69 +++++++++++-- src/composite_model/selectors.jl | 9 +- test/test-manual-many-input-filter.jl | 59 ++++++++++++ test/test-model-api-stabilization.jl | 13 ++- test/test-model-bound-many.jl | 36 +++++++ test/test-model-initializers.jl | 101 ++++++++++++++++++- test/test-model-multirate-integration.jl | 109 +++++++++++++++++++++ test/test-model-object-id.jl | 28 +++++- test/test-model-output-boundaries.jl | 42 ++++++++ 11 files changed, 540 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc119fc4f..017986e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -211,7 +211,19 @@ assembly and coupling. ### Fixed - Retained outputs and temporal dependency streams snapshot mutable values, so - later in-place model updates do not overwrite historical samples. + later in-place model updates do not overwrite historical samples. This also + covers the initial values of output requests, including selector reentry. +- Temporal history buffers grow when newly added consumers need longer windows. + `PreviousTimeStep` retains the preceding publication even when fractional + clocks or manual calls leave gaps between publications. +- `Many` inputs respect application and process filters for local producers. + Positional `Relation(...)` selectors keep separate bindings for each consumer + after objects are added. +- Newborn initializers can read fully initialized sources registered in the same + lifecycle event. Unrelated distributed outputs no longer hide newborn sources + from `Many` inputs. +- Mixed-type object IDs with the same text remain distinct during sorting and + identity-based lookup. - Temporal inputs and `final_state` snapshots no longer alias nested mutable producer values. Explicitly declared environment durations also survive forcing-source remapping without duplicate fields or replacement. diff --git a/src/composite_model/compilation.jl b/src/composite_model/compilation.jl index cfa4ad247..6d9a2dccf 100644 --- a/src/composite_model/compilation.jl +++ b/src/composite_model/compilation.jl @@ -2185,7 +2185,7 @@ function _many_binding_scope_anchor( consumer_id::ObjectId, ) selector_criteria = criteria(selector) - !isnothing(_criteria_get(selector_criteria, :relation, nothing)) && + !isnothing(_criteria_value(selector_criteria, :relation, Relation)) && return (:consumer, consumer_id) explicit_scope = _criteria_scope(selector_criteria) scope = isnothing(explicit_scope) ? @@ -2457,6 +2457,7 @@ function _append_added_many_sources!( binding.application, applications_by_id, distributed_outputs, + applications_by_object, ) isempty(new_source_ids) && return true @@ -2541,6 +2542,9 @@ function _update_structural_many_sources!( model::CompositeModel, binding::CompiledModelInputBinding, dirty_ids, + applications_by_object, + applications_by_id, + distributed_outputs, ) binding.multiplicity == :many || return :fallback binding.carrier_hint == :ref_vector || return :fallback @@ -2559,6 +2563,14 @@ function _update_structural_many_sources!( context=binding.consumer_id, default_to_context=true, default_scope=default_scope, + ) && _matches_input_source_writer( + object_id, + binding.source_var, + binding.process, + binding.application, + applications_by_id, + distributed_outputs, + applications_by_object, ) was_source == is_source && continue source_reference = nothing @@ -3602,6 +3614,9 @@ function _extend_compiled_scene( model, binding, structural_dirty_ids, + applications_by_object, + applications_by_id, + distributed_outputs, ) if structural_update != :fallback processed_many_sources[binding.source_ids] = nothing @@ -7147,6 +7162,7 @@ Base.@nospecializeinfer function _push_model_input_binding!( application_filter, applications_by_id, distributed_outputs, + applications_by_object, ) selector isa Many && sizehint!(source_ids, length(source_ids) + 1) source_application_ids = if _selector_from_status(selector) @@ -7328,16 +7344,6 @@ function _final_many_source_applications( return isempty(canonical_ids) ? source_application_ids : canonical_ids end -_filter_many_input_sources_by_writer!( - source_ids, - selector, - source_var, - process_filter, - application_filter, - applications_by_id, - ::NoCompiledDistributedOutputs, -) = source_ids - function _filter_many_input_sources_by_writer!( source_ids, selector, @@ -7345,46 +7351,77 @@ function _filter_many_input_sources_by_writer!( process_filter, application_filter, applications_by_id, - distributed_outputs::CompiledDistributedOutputs, + distributed_outputs, + applications_by_object, ) selector isa Many || return source_ids _selector_from_status(selector) && return source_ids isnothing(process_filter) && isnothing(application_filter) && return source_ids filter!(source_ids) do source_id - owners = get( - distributed_outputs.writer_ownership, - (source_id, source_var), - (), + return _matches_input_source_writer( + source_id, + source_var, + process_filter, + application_filter, + applications_by_id, + distributed_outputs, + applications_by_object, ) - owned = any(owners) do owner - source_application = get( - applications_by_id, - owner.application_id, - nothing, - ) - isnothing(source_application) && return false - isnothing(process_filter) || - source_application.process == process_filter || return false - isnothing(application_filter) || - source_application.id == application_filter || return false - return true - end - owned && return true - # Manual callees (and stream-only local outputs) are not scheduled - # canonical owners. They remain valid explicitly selected producers; - # unrelated distributed outputs must not hide their local targets. - return any(values(applications_by_id)) do application - isnothing(process_filter) || application.process == process_filter || return false - isnothing(application_filter) || application.id == application_filter || return false - return _application_writes_object_variable( - NoCompiledDistributedOutputs(), application, source_id, source_var, - ) - end end return source_ids end +function _matches_input_source_writer( + source_id, + source_var, + process_filter, + application_filter, + applications_by_id, + distributed_outputs, + applications_by_object, +) + # This index also contains manual/stream-only writers and the targeted + # initializer overlay, whose newborns are not in compiled target_ids yet. + local_writer = any(get(applications_by_object, source_id, ())) do application + isnothing(process_filter) || application.process == process_filter || return false + isnothing(application_filter) || application.id == application_filter || return false + return haskey(_local_output_schema(application.spec), source_var) + end + local_writer && return true + return _has_matching_distributed_input_writer( + distributed_outputs, + source_id, + source_var, + process_filter, + application_filter, + applications_by_id, + ) +end + +_has_matching_distributed_input_writer( + ::NoCompiledDistributedOutputs, + args..., +) = false + +function _has_matching_distributed_input_writer( + distributed_outputs::CompiledDistributedOutputs, + source_id, + source_var, + process_filter, + application_filter, + applications_by_id, +) + owners = get(distributed_outputs.writer_ownership, (source_id, source_var), ()) + return any(owners) do owner + application = get(applications_by_id, owner.application_id, nothing) + isnothing(application) && return false + isnothing(process_filter) || application.process == process_filter || return false + isnothing(application_filter) || application.id == application_filter || return false + return true + end +end + function _model_input_names(application::CompiledModelApplication) return Symbol[Symbol(var) for var in keys(_input_schema(application.spec))] end diff --git a/src/composite_model/runtime_outputs.jl b/src/composite_model/runtime_outputs.jl index 9bd1e0659..5d4719611 100644 --- a/src/composite_model/runtime_outputs.jl +++ b/src/composite_model/runtime_outputs.jl @@ -39,6 +39,23 @@ function TemporalDependencyBuffer{T}(capacity::Integer) where {T} ) end +function _grow_temporal_dependency_buffer!( + buffer::TemporalDependencyBuffer{T}, + capacity::Int, +) where {T} + capacity <= length(buffer.times) && return buffer + times = Vector{Float64}(undef, capacity) + values = Vector{T}(undef, capacity) + for index in eachindex(buffer) + times[index], values[index] = buffer[index] + end + # Keep the buffer itself: compiled consumers and publishers share it. + buffer.times = times + buffer.values = values + buffer.first_slot = 1 + return buffer +end + Base.IndexStyle(::Type{<:TemporalDependencyBuffer}) = IndexLinear() Base.size(buffer::TemporalDependencyBuffer) = (buffer.sample_count,) @@ -1520,7 +1537,16 @@ function _initialize_model_output_stream!( sizehint_steps::Integer, ) key = _model_stream_key(application.id, object_id, variable) - haskey(streams, key) && return streams + if haskey(streams, key) + stream = streams[key] + if stream isa TemporalDependencyBuffer + _grow_temporal_dependency_buffer!( + stream, + _model_dependency_capacity(retention, application.id, variable), + ) + end + return streams + end reference = _model_output_reference( compiled, application, @@ -1710,7 +1736,10 @@ end cutoff = output.dependency_horizon <= 0.0 ? float(time) : float(time) - output.dependency_horizon + 1.0 - while !isempty(stream) && + # The preceding publication is needed by PreviousTimeStep and linear + # extrapolation even when fractional clocks or manual calls leave a + # larger gap than the nominal cadence. Capacity still bounds storage. + while length(stream) > 2 && first(stream)[1] < cutoff - 1.0e-8 _temporal_dependency_popfirst!(stream) end @@ -5610,6 +5639,7 @@ mutable struct _TargetedTopologyRuntime{CS,MA} <: ObjectId, Vector{CompiledModelApplication}, } + indexed_addition_count::Int manual_application_ids::MA application_positions::Dict{Symbol,Int} end @@ -5644,6 +5674,7 @@ function _targeted_topology_runtime!( Union{Nothing,_TargetedApplicationSet}, }(), Dict{ObjectId,Vector{CompiledModelApplication}}(), + 0, compiled.scenario_plan.manual_application_ids, Dict( application_id => index @@ -5698,7 +5729,7 @@ function _targeted_application_set!( requested_ids, ) key = Tuple(requested_ids) - return get!(runtime.application_sets, key) do + application_set = get!(runtime.application_sets, key) do applications = _new_object_applications( model, runtime.compiled, @@ -5706,9 +5737,6 @@ function _targeted_application_set!( ) isnothing(applications) && return nothing output_applications, added_applications_by_object = applications - # Keep applications for every object targeted earlier in this same - # lifecycle delta. A later newborn can then bind an input to an earlier - # newborn without refreshing the whole scene at a mid-kernel barrier. merge!( runtime.added_applications_by_object, added_applications_by_object, @@ -5722,6 +5750,31 @@ function _targeted_application_set!( false, ) end + isnothing(application_set) && return nothing + + # A fully initialized source can be registered without an explicit call. + # Include its application membership before resolving newborn inputs. The + # append-only delta cursor visits each addition once across a chain of calls; + # targets prepared above already have membership and need no extra lookup. + added = lifecycle_delta(model).added + unindexed_ids = nothing + for index in (runtime.indexed_addition_count + 1):length(added) + object_id = added[index].id + haskey(runtime.added_applications_by_object, object_id) && continue + isnothing(unindexed_ids) && (unindexed_ids = ObjectId[]) + push!(unindexed_ids, object_id) + end + if !isnothing(unindexed_ids) + applications = _new_object_applications( + model, + runtime.compiled, + unindexed_ids, + ) + isnothing(applications) && return nothing + merge!(runtime.added_applications_by_object, last(applications)) + end + runtime.indexed_addition_count = length(added) + return application_set end function _targeted_callee_applications( @@ -7225,7 +7278,7 @@ function _output_request_target( OutputRequestMembership( float(start_time), nothing, - initial, + deepcopy(initial), ), ], ) @@ -7381,7 +7434,7 @@ function _refresh_output_request_targets!( OutputRequestMembership( start_time, nothing, - initial, + deepcopy(initial), ), ) continue diff --git a/src/composite_model/selectors.jl b/src/composite_model/selectors.jl index b4cf774b6..92cfa6af5 100644 --- a/src/composite_model/selectors.jl +++ b/src/composite_model/selectors.jl @@ -478,8 +478,13 @@ end function _object_id_isless(left::ObjectId, right::ObjectId) left_value = left.value right_value = right.value - if typeof(left_value) === typeof(right_value) && - hasmethod(isless, Tuple{typeof(left_value),typeof(right_value)}) + # Keep each identity type in one ordered group. Mixing natural ordering + # within a type with string ordering between types is not transitive, and + # distinct identities such as `1` and `Symbol("1")` otherwise compare equal. + if typeof(left_value) !== typeof(right_value) + return isless(string(typeof(left_value)), string(typeof(right_value))) + end + if hasmethod(isless, Tuple{typeof(left_value),typeof(right_value)}) return isless(left_value, right_value) end return isless(string(left_value), string(right_value)) diff --git a/test/test-manual-many-input-filter.jl b/test/test-manual-many-input-filter.jl index b50348d4c..bfaae3d5d 100644 --- a/test/test-manual-many-input-filter.jl +++ b/test/test-manual-many-input-filter.jl @@ -204,3 +204,62 @@ end end end end + +@testset "Many filters local producers before wiring source objects" begin + for filter_kind in (:application, :process), distributed_output in (false, true) + @testset "$filter_kind filter, distributed_output=$distributed_output" begin + source_filter = filter_kind == :application ? + (application=:selected_source,) : (process=:private_many_filter_source,) + selector = Many(; scale=:Leaf, within=Subtree(), var=:potential, + source_filter...) + unrelated = distributed_output ? ( + ModelSpec(ManualManyFilterUnrelatedWriter(); name=:unrelated_writer, + on=One(scale=:Scene), + outputs_to=(OutputTo(Many(scale=:Marker, within=Subtree()); + vars=(:marker_value,)),)), + ) : () + model = CompositeModel( + Object(:scene; scale=:Scene), + Object(:leaf_a; scale=:Leaf, kind=:selected, parent=:scene), + Object(:leaf_unrelated; scale=:Leaf, kind=:unrelated, parent=:scene), + Object(:leaf_supplied; scale=:Leaf, kind=:supplied, parent=:scene, + status=Status(potential=10_000.0)), + Object(:leaf_outside; scale=:Leaf, kind=:supplied, + status=Status(potential=30_000.0)), + Object(:marker; scale=:Marker, parent=:scene); + applications=( + ModelSpec(PrivateManyFilterSource(); name=:selected_source, + on=Many(scale=:Leaf, kind=:selected)), + ModelSpec(PrivateManyFilterCanonicalSource(); name=:other_source, + on=Many(scale=:Leaf, kind=:unrelated)), + ModelSpec(PrivateManyFilterReader(selector); name=:reader, + on=One(scale=:Scene)), + unrelated..., + ), + ) + simulation = run!(model; steps=1, outputs=:none) + binding = only(Diagnostics.explain_bindings(model)) + @test binding.source_ids == [:leaf_a] + @test binding.source_application_ids == [:selected_source] + @test model_status(model, :scene).observed_private_potential == -2.0 + @test model_status(model, :leaf_supplied).potential == 10_000.0 + register_object!(model, Object(:leaf_b; scale=:Leaf, kind=:selected, + parent=:scene)) + register_object!(model, Object(:leaf_z; scale=:Leaf, kind=:unrelated, + parent=:scene)) + register_object!(model, Object(:leaf_zz_supplied; scale=:Leaf, kind=:supplied, + parent=:scene, status=Status(potential=20_000.0))) + continue!(simulation; steps=1) + binding = only(Diagnostics.explain_bindings(model)) + @test binding.source_ids == [:leaf_a, :leaf_b] + @test model_status(model, :scene).observed_private_potential == -5.0 + @test model_status(model, :leaf_zz_supplied).potential == 20_000.0 + reparent_object!(model, :leaf_outside, :scene) + continue!(simulation; steps=1) + binding = only(Diagnostics.explain_bindings(model)) + @test binding.source_ids == [:leaf_a, :leaf_b] + @test model_status(model, :scene).observed_private_potential == -7.0 + @test model_status(model, :leaf_outside).potential == 30_000.0 + end + end +end diff --git a/test/test-model-api-stabilization.jl b/test/test-model-api-stabilization.jl index d6d96564b..e0a65cd15 100644 --- a/test/test-model-api-stabilization.jl +++ b/test/test-model-api-stabilization.jl @@ -2176,7 +2176,8 @@ end :previous_signals => Many( scale=:Leaf, within=Subtree(), - application=:source, + # Include modeled and supplied values; discover writers + # without restricting the selected objects to one producer. var=:signal, ), ), @@ -2217,6 +2218,16 @@ end @test refreshed_binding.source_ids == ObjectId.([:leaf_1, :leaf_2]) @test refreshed_binding.source_application_ids == [:source] @test plant.lagged_total == 12.0 + + reparent_object!(model, :leaf_2, :scene) + continue!(simulation) + @test only(Diagnostics.explain_bindings(model)).source_ids == [:leaf_1] + @test plant.lagged_total == 3.0 + + reparent_object!(model, :leaf_2, :plant) + continue!(simulation) + @test only(Diagnostics.explain_bindings(model)).source_ids == [:leaf_1, :leaf_2] + @test plant.lagged_total == 14.0 end @testset "new consumers reuse an updated plant-wide Many carrier" begin diff --git a/test/test-model-bound-many.jl b/test/test-model-bound-many.jl index de1a5cb44..4c8dcd3e1 100644 --- a/test/test-model-bound-many.jl +++ b/test/test-model-bound-many.jl @@ -578,3 +578,39 @@ end @testset "bound_input errors name the compiled context" begin @test_throws ArgumentError bound_input(nothing, :signals) end + +@testset "relative Many inputs retain each newborn consumer's children" begin + for positional in (false, true) + @testset "positional=$positional" begin + selector = positional ? + Many(Relation(:children); within=SceneScope(), scale=:Leaf, + var=:signal, from_status=true) : + Many(; relation=:children, within=SceneScope(), scale=:Leaf, + var=:signal, from_status=true) + model = CompositeModel( + Object(:plant_a; scale=:Plant), + Object(:leaf_a; scale=:Leaf, parent=:plant_a, + status=Status(signal=1.0)); + applications=(ModelSpec(BoundManySignalProbeModel(); + name=:bound_probe, on=Many(scale=:Plant), + inputs=(:signals => selector,)),), + ) + simulation = run!(model; steps=1, outputs=:none) + register_object!(model, Object(:plant_b; scale=:Plant)) + register_object!(model, Object(:leaf_b; scale=:Leaf, parent=:plant_b, + status=Status(signal=100.0))) + continue!(simulation; steps=1) + @test model_status(model, :plant_a).seen_ids == [ObjectId(:leaf_a)] + @test model_status(model, :plant_a).total == 1.0 + @test model_status(model, :plant_b).seen_ids == [ObjectId(:leaf_b)] + @test model_status(model, :plant_b).total == 100.0 + register_object!(model, Object(:leaf_c; scale=:Leaf, parent=:plant_b, + status=Status(signal=20.0))) + continue!(simulation; steps=1) + @test model_status(model, :plant_a).total == 1.0 + @test model_status(model, :plant_b).seen_ids == + ObjectId[ObjectId(:leaf_b), ObjectId(:leaf_c)] + @test model_status(model, :plant_b).total == 120.0 + end + end +end diff --git a/test/test-model-initializers.jl b/test/test-model-initializers.jl index c8a67b679..260e43637 100644 --- a/test/test-model-initializers.jl +++ b/test/test-model-initializers.jl @@ -379,7 +379,11 @@ function PlantSimEngine.run!( end struct InitializerChainCreatorModel <: - AbstractInitializer_Chain_CreatorModel end + AbstractInitializer_Chain_CreatorModel + initialize_source::Bool +end + +InitializerChainCreatorModel() = InitializerChainCreatorModel(true) PlantSimEngine.inputs_(::InitializerChainCreatorModel) = NamedTuple() PlantSimEngine.outputs_(::InitializerChainCreatorModel) = ( @@ -388,7 +392,7 @@ PlantSimEngine.outputs_(::InitializerChainCreatorModel) = ( ) function PlantSimEngine.run!( - ::InitializerChainCreatorModel, + model::InitializerChainCreatorModel, status, environment, constants, @@ -403,10 +407,10 @@ function PlantSimEngine.run!( scale=:Leaf, kind=:ChainSource, parent=:plant, - status=Status(signal=0.0), + status=Status(signal=model.initialize_source ? 0.0 : 7.0), ), ) - run_initializer!(context, :source, source) + model.initialize_source && run_initializer!(context, :source, source) sink = register_object!( runtime, Object( @@ -422,6 +426,25 @@ function PlantSimEngine.run!( return nothing end +struct InitializerChainManySinkModel <: + AbstractInitializer_Chain_SinkModel end + +PlantSimEngine.inputs_(::InitializerChainManySinkModel) = ( + source_signal=Required(Vector{Float64}), +) +PlantSimEngine.outputs_(::InitializerChainManySinkModel) = (seen=-1.0,) + +function PlantSimEngine.run!( + ::InitializerChainManySinkModel, + status, + environment, + constants, + context, +) + status.seen = sum(status.source_signal; init=0.0) + return nothing +end + struct InitializerSplitCreatorModel <: AbstractInitializer_Split_CreatorModel end @@ -1196,6 +1219,76 @@ end @test model_status(model, :scene).sink_seen == 7.0 end +@testset "newborn inputs include every registered source application" begin + for initialize_source in (false, true), + many in (false, true), + unrelated_distributed in (false, true) + + @testset "initialized=$initialize_source many=$many distributed=$unrelated_distributed" begin + multiplicity = many ? Many : One + sink_model = many ? + InitializerChainManySinkModel() : + InitializerChainSinkModel() + applications = ( + ModelSpec( + InitializerChainSourceModel(); + name=:chain_source, + on=Many(scale=:Leaf, kind=:ChainSource), + ), + ModelSpec( + sink_model; + name=:chain_sink, + on=Many(scale=:Leaf, kind=:ChainSink), + inputs=( + source_signal=multiplicity( + scale=:Leaf, + kind=:ChainSource, + within=SceneScope(), + application=:chain_source, + var=:signal, + ), + ), + ), + ModelSpec( + InitializerChainCreatorModel(initialize_source); + name=:chain_creator, + on=One(scale=:Scene), + calls=( + source=Initializer( + One(kind=:ChainSource, application=:chain_source), + ), + sink=Initializer( + One(kind=:ChainSink, application=:chain_sink), + ), + ), + ), + ) + if unrelated_distributed + applications = ( + applications..., + ModelSpec( + InitializerDistributedWriterModel(1.0); + name=:unrelated_distributed, + on=One(scale=:Scene), + outputs_to=(OutputTo(One(scale=:Scene)),), + ), + ) + end + model = CompositeModel( + Object(:scene; scale=:Scene), + Object(:plant; scale=:Plant, parent=:scene); + applications=applications, + environment=(duration=Hour(1),), + ) + + run!(model; steps=1, outputs=:none) + @test model_status(model, :chain_source_object).signal == 7.0 + @test model_status(model, :chain_sink_object).seen == 7.0 + @test model_status(model, :scene).sink_seen == 7.0 + end + end +end + @testset "newborn overlay spans creator targets before the barrier" begin model = CompositeModel( Object(:scene; scale=:Scene), diff --git a/test/test-model-multirate-integration.jl b/test/test-model-multirate-integration.jl index 8b08468c2..b68dcece6 100644 --- a/test/test-model-multirate-integration.jl +++ b/test/test-model-multirate-integration.jl @@ -96,6 +96,115 @@ function PlantSimEngine.run!( return nothing end +PlantSimEngine.@process "temporal_retention_source" verbose = false +PlantSimEngine.@process "temporal_retention_probe" verbose = false +struct TemporalRetentionSourceModel <: AbstractTemporal_Retention_SourceModel end +struct TemporalRetentionProbeModel <: AbstractTemporal_Retention_ProbeModel + history::Vector{Float64} +end +PlantSimEngine.inputs_(::TemporalRetentionSourceModel) = NamedTuple() +PlantSimEngine.outputs_(::TemporalRetentionSourceModel) = (signal=0.0,) +function PlantSimEngine.run!(::TemporalRetentionSourceModel, status, environment, constants, context) + status.signal += 1.0 + return nothing +end +PlantSimEngine.inputs_(::TemporalRetentionProbeModel) = + (current=Required(Float64), sample=Required(Float64)) +PlantSimEngine.outputs_(::TemporalRetentionProbeModel) = (observed=0.0,) +function PlantSimEngine.run!(model::TemporalRetentionProbeModel, status, environment, constants, context) + status.observed = status.sample + push!(model.history, status.observed) + return nothing +end + +@testset "sparse clocks preserve preceding publications with bounded retention" begin + for policy in (PreviousTimeStep(:sample), Interpolate()) + previous = policy isa PreviousTimeStep + histories = Vector{Float64}[] + for retention in (:none, :all) + history = Float64[] + temporal_input = previous ? + policy => One(scale=:Cell, application=:source, var=:signal) : + :sample => One(scale=:Cell, application=:source, var=:signal, policy=policy) + model = CompositeModel(Object(:cell; scale=:Cell, status=Status(sample=0.0)); applications=( + ModelSpec( + TemporalRetentionSourceModel(); name=:source, on=One(scale=:Cell), + every=ClockSpec(2.5, previous ? 0.5 : 1.0), + ), + ModelSpec( + TemporalRetentionProbeModel(history); name=:probe, on=One(scale=:Cell), + inputs=( + :current => One(scale=:Cell, application=:source, var=:signal), + temporal_input, + ), + ), + )) + simulation = run!(model; steps=8, outputs=retention) + continue!(simulation; steps=10) + push!(histories, history) + if retention === :none + stream = outputs(simulation)[(:source, ObjectId(:cell), :signal)] + @test stream isa PlantSimEngine.TemporalDependencyBuffer + @test length(stream) == 2 + @test length(stream.times) == 4 + end + end + expected = previous ? + Float64[count(sample_time -> sample_time < time, 3:5:18) for time in 1:18] : + [time < 6 ? 1.0 : 1.0 + (time - 1) / 5 for time in 1:18] + @test histories[1] ≈ expected + @test histories[2] ≈ expected + end +end + +@testset "new consumers expand wrapped temporal buffers without replacing them" begin + totals = Float64[] + for retention in (:none, :all) + short_history, long_history = Float64[], Float64[] + consumer_spec(name, scale, window, history) = ModelSpec( + TemporalRetentionProbeModel(history); name=name, on=Many(scale=scale), + inputs=( + :current => One(scale=:Source, within=SceneScope(), application=:source, var=:signal), + :sample => One( + scale=:Source, within=SceneScope(), application=:source, var=:signal, + policy=Integrate(), window=window, + ), + ), + ) + model = CompositeModel( + Object(:source; scale=:Source), Object(:short; scale=:Short); + applications=( + ModelSpec(TemporalRetentionSourceModel(); name=:source, on=One(scale=:Source)), + consumer_spec(:short, :Short, 2.0, short_history), + consumer_spec(:long, :Long, 5.0, long_history), + ), + ) + simulation = run!(model; steps=5, outputs=retention) + key = (:source, ObjectId(:source), :signal) + stream = outputs(simulation)[key] + if retention === :none + @test stream.first_slot != 1 + @test collect(stream) == [(4.0, 4.0), (5.0, 5.0)] + end + register_object!(model, Object(:long; scale=:Long)) + continue!(simulation; steps=0) + @test outputs(simulation)[key] === stream + if retention === :none + @test length(stream.times) == 5 + @test collect(stream) == [(4.0, 4.0), (5.0, 5.0)] + end + continue!(simulation; steps=5) + @test last(short_history) == 19.0 + @test last(long_history) == 40.0 + push!(totals, final_state(simulation, :long).observed) + if retention === :none + @test collect(stream) == [(Float64(time), Float64(time)) for time in 6:10] + @test length(stream.times) == 5 + end + end + @test totals == [40.0, 40.0] +end + @testset "48-hour hourly/daily stack and plant isolation" begin model = CompositeModel( Object(:scene; scale=:Scene), diff --git a/test/test-model-object-id.jl b/test/test-model-object-id.jl index 64c91be49..720702363 100644 --- a/test/test-model-object-id.jl +++ b/test/test-model-object-id.jl @@ -218,6 +218,32 @@ end @test MultiScaleTreeGraph.node_id(detached) == 3 end +@testset "mixed identity types remain distinct in ordered input views" begin + entries = ( + 1 => 1.0, + Symbol("1") => 101.0, + Int32(1) => 201.0, + 2 => 2.0, + 10 => 10.0, + Symbol("15") => 115.0, + ) + model = CompositeModel(( + Object(id; scale=:Leaf, status=Status(signal=value)) + for (id, value) in entries + )...) + ids = resolve_object_ids(model, Many(scale=:Leaf)) + @test length(ids) == length(entries) + @test length(unique(ids)) == length(entries) + values = BoundMany(ids, [model_status(model, id).signal for id in ids]) + for (id, value) in entries + @test values[ObjectId(id)] == value + end + values[ObjectId(Symbol("1"))] = -1.0 + @test values[ObjectId(1)] == 1.0 + @test values[ObjectId(Int32(1))] == 201.0 + @test values[ObjectId(Symbol("1"))] == -1.0 +end + @testset "MTG identities remain stable after source mutation" begin root = Node(MultiScaleTreeGraph.NodeMTG("/", :Scene, 1, 0)) plant = Node(root, MultiScaleTreeGraph.NodeMTG("+", :Plant, 1, 1)) @@ -438,7 +464,7 @@ end @test Set(object_ids(model; id=[101, compound])) == Set([ObjectId(101), compound]) @test resolve_object_ids(model, OptionalOne(id=999)) == ObjectId[] @test_throws ErrorException resolve_object_ids(model, One(id=999)) - @test resolve_object_ids(model, Many(within=Scope(101))) == [compound, ObjectId(101)] + @test Set(resolve_object_ids(model, Many(within=Scope(101)))) == Set([compound, ObjectId(101)]) @test resolve_object_ids(model, One(within=Scope(compound))) == [compound] @test_throws ErrorException resolve_object_ids(model, One(within=Scope(:upper_leaf))) @test_throws "Unsupported object selector keyword" One(name=:upper_leaf) diff --git a/test/test-model-output-boundaries.jl b/test/test-model-output-boundaries.jl index f9526ba79..5a6a863a1 100644 --- a/test/test-model-output-boundaries.jl +++ b/test/test-model-output-boundaries.jl @@ -94,6 +94,48 @@ end end end +@testset "requested mutable initial values survive continuation and reentry" begin + model = CompositeModel( + Object(:scene; scale=:Scene), + Object(:plant_1; scale=:Plant, parent=:scene), + Object(:plant_2; scale=:Plant, parent=:scene), + Object(:leaf; scale=:Leaf, parent=:plant_1); + applications=(ModelSpec( + BoundaryMutableSourceModel(); name=:source, on=Many(scale=:Leaf), + every=ClockSpec(2.0, 0.0), + ),), + ) + requests = [ + OutputRequest( + Many(scale=:Leaf, within=Subtree()), variable; + application=:source, context=:plant_1, + ) for variable in (:values, :nested) + ] + simulation = run!(model; steps=2, outputs=requests) + first_rows = collect_outputs(simulation; sink=nothing) + @test getproperty.(first_rows[:values], :value) == [[0.0], [1.0]] + @test [row.value.values for row in first_rows[:nested]] == [[0.0], [10.0]] + + reparent_object!(model, :leaf, :plant_2) + continue!(simulation; steps=2) + reparent_object!(model, :leaf, :plant_1) + continue!(simulation; steps=2) + reentry_rows = collect_outputs(simulation; sink=nothing) + @test getproperty.(reentry_rows[:values], :timestep) == [1, 2, 5, 6] + @test getproperty.(reentry_rows[:values], :value) == [[0.0], [1.0], [2.0], [3.0]] + @test [row.value.values for row in reentry_rows[:nested]] == + [[0.0], [10.0], [20.0], [30.0]] + + continue!(simulation; steps=2) + final_rows = collect_outputs(simulation; sink=nothing) + @test getproperty.(first_rows[:values], :value) == [[0.0], [1.0]] + @test getproperty.(reentry_rows[:values], :value) == [[0.0], [1.0], [2.0], [3.0]] + @test getproperty.(final_rows[:values], :value) == + [[0.0], [1.0], [2.0], [3.0], [3.0], [4.0]] + @test [row.value.values for row in final_rows[:nested]] == + [[0.0], [10.0], [20.0], [30.0], [30.0], [40.0]] +end + @testset "temporal consumers cannot mutate source snapshots through private inputs" begin for variant in (:nested, :many) initial = variant === :nested ? (values=[0.0],) : [[0.0]]