From 6550b3eecffdd785f265b4445246f3c6397a57a3 Mon Sep 17 00:00:00 2001 From: Thomas Purdy Date: Thu, 31 Aug 2023 22:23:56 -0600 Subject: [PATCH] Created Composition DSL. Is of the form sfcompose(sf1, sf2, quote (s1, s2) s1, s2 ^ A => N end) Where sf1 and sf2 are stockflows, the first line is an ordered tuple of symbols which will act as its corresponding stockflow, and stockflows are separated from feet with ^. This commit includes Composition.jl, a testing file, and slight changes to Syntax.jl in src and tests; in src, I include Composition.jl, and in tests, I run the Composition tests from Syntax. --- src/Syntax.jl | 1 + src/syntax/Composition.jl | 161 +++++++++++++++++++++++++++++ test/Composition.jl | 112 ++++++++++++++++++++ test/Syntax.jl | 208 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 482 insertions(+) create mode 100755 src/syntax/Composition.jl create mode 100644 test/Composition.jl mode change 100644 => 100755 test/Syntax.jl diff --git a/src/Syntax.jl b/src/Syntax.jl index ec5aa14f..8cf49a28 100644 --- a/src/Syntax.jl +++ b/src/Syntax.jl @@ -1031,5 +1031,6 @@ function match_foot_format(footblock::Expr) end +include("syntax/Composition.jl") end diff --git a/src/syntax/Composition.jl b/src/syntax/Composition.jl new file mode 100755 index 00000000..266e5f5c --- /dev/null +++ b/src/syntax/Composition.jl @@ -0,0 +1,161 @@ +module Composition +export sfcompose + +using ...StockFlow +using ..Syntax +using Catlab.CategoricalAlgebra +using Catlab.WiringDiagrams + +import ..Syntax: create_foot +import Catlab.Programs.RelationalPrograms: UntypedUnnamedRelationDiagram + + +RETURN_UWD = false + +""" +Construct a uwd to compose your open stockflows +""" +function create_uwd(; + Box::Vector{Symbol} = Vector{Symbol}(), # stockflows + Port::Vector{Tuple{Int, Int}} = Vector{Tuple{Int, Int}}(), # stockflow => foot number, for each foot on stockflow + OuterPort::Vector{Int} = Vector{Int}(), # unique feet number (1:n) + Junction::Vector{Symbol} = Vector{Symbol}() # A symbol for each (unique) foot + ) + + uwd = UntypedUnnamedRelationDiagram{Symbol, Symbol}(0) + add_parts!(uwd, :Box, length(Box), name=Box) + add_parts!(uwd, :Junction, length(Junction), variable=Junction) + add_parts!(uwd, :Port, length(Port), box=map(first, Port), junction=map(last, Port)) + add_parts!(uwd, :OuterPort, length(OuterPort), outer_junction=OuterPort) + return uwd +end + +""" +Parse expression of form A ^ B => C, extract sf A and foot B => C +""" +function interpret_center_of_composition_statement(center::Expr)::Tuple{Symbol, Expr} # sf, foot defintion + @assert length(center.args) == 3 && center.args[1] == :(=>) && typeof(center.args[2]) == Expr "Invalid argument: expected A ^ B => C, A ^ () => C or A ^ B => (), got $center" + # third argument can be symbol or (), the latter of which is an Expr + center_caret_statement = center.args[2] + @assert length(center_caret_statement.args) == 3 && center_caret_statement.args[1] == :^ && typeof(center_caret_statement.args[2]) == Symbol "Invalid center argument: expected A ^ B or A ^ (), got $center" + # third argument here, too, can be symbol or () + return (center_caret_statement.args[2], Expr(:call, :(=>), center_caret_statement.args[3], center.args[3])) +end + +""" +Go line by line and associate stockflows and feet +""" +function interpret_composition_notation(mapping_pair::Expr)::Tuple{Vector{Symbol}, StockAndFlow0} + + if mapping_pair.head == :call # (A ^ B => C) case (incl where B or C are ()) + sf, foot_def = interpret_center_of_composition_statement(mapping_pair) + return [sf], create_foot(foot_def) + end + + expr_args = mapping_pair.args + stockflows = collect(Base.Iterators.takewhile(x -> typeof(x) == Symbol, expr_args)) + center_index = length(stockflows) + 1 + @assert center_index <= length(expr_args) "A tuple is an invalid expression for composition syntax. Expected argument of form sf1, sf2, ... ^ stock1 => sum1, stock2 => sum2, ..." + center = expr_args[center_index] + + foot_temp = Vector{Expr}() + + sf, foot_def = interpret_center_of_composition_statement(center) + push!(foot_temp, foot_def) + push!(stockflows, sf) + append!(foot_temp, expr_args[center_index+1:end]) + + return (stockflows, create_foot(Expr(:tuple, foot_temp...))) +end + + +""" +sirv = sfcompose(sir, svi, quote + (sr, sv) + sr, sv ^ S => N, I => N +end) + +Cannot use () => () as a foot, +the length of the first tuple must be the same as the number of stock flows given as argument, +and every foot can only be used once. +""" +function sfcompose(args...) #(sf1, sf2, ..., block) + + @assert length(args) > 0 "Didn't get any arguments!" + + block = args[end] + @assert typeof(block) == Expr "Didn't get an expression block for last argument!" + + sfs = args[1:end-1] + + + @assert all(sf -> typeof(sf) <: AbstractStockAndFlowF, sfs) "Not all arguments before the block are stock flows!" + + + Base.remove_linenums!(block) + + sf_names::Vector{Symbol} = block.args[1].args # first line are the names you want to use for the ordered arguments. + # That is, first line needs to be a tuple, with the first argument being what you'll call the first stockflow + + if length(sfs) == 0 # Composing 0 stock flows should give you an empty stock flow + return StockAndFlowF() + end + + @assert length(sf_names) == length(sfs) "The number of symbols on the first line is not the same as the number of stock flow arguments provided. Stockflow #: $(length(sfs)) Symbol #: $(length(sf_names))" + + + + @assert allunique(sf_names) "Not all choices of names for stock flows are unique!" + + + empty_foot = (@foot () => ()) + + + # symbol representation of sf => (sf itself, sf's feet) + # Every sf has empty foot as first foot to get around being unable to create OpenStockAndFlowF without feet + sf_map::Dict{Symbol, Tuple{AbstractStockAndFlowF, Vector{StockAndFlow0}}} = Dict(sf_names[i] => (sfs[i], [empty_foot]) for i ∈ eachindex(sf_names)) # map the symbols to their corresponding stockflows + + # all feet + feet_index_dict::Dict{StockAndFlow0, Int} = Dict(empty_foot => 1) + for statement in block.args[2:end] + stockflows, foot = interpret_composition_notation(statement) + # adding new foot to list + @assert (foot ∉ keys(feet_index_dict)) "Foot has already been used, or you are using an empty foot!" + push!(feet_index_dict, foot => length(feet_index_dict) + 1) + for stockflow in stockflows + # adding this foot to each stock flow to its left + push!(sf_map[stockflow][2], foot) + end + end + + Box::Vector{Symbol} = sf_names + + + Port = Vector{Tuple{Int, Int}}() + + for (k, v) ∈ sf_map # TODO: Just find a better way to do this. + for foot ∈ v[2] + push!(Port, (findfirst(x -> x == k, sf_names), feet_index_dict[foot])) + end + end + + Junction::Vector{Symbol} = [gensym() for _ ∈ 1:length(feet_index_dict)] + OuterPort::Vector{Int} = collect(1:length(feet_index_dict)) + + uwd = create_uwd(Box=Box, Port=Port, Junction=Junction, OuterPort=OuterPort) + + # I'd prefer this to be a vector, but oapply didn't like that + # I'd also prefer that I don't include the empty foot, but Open doesn't want to accept stockflows with no feet. + # open_stockflows::AbstractDict = Dict(sf_key => Open(sf_val, foot_dict[sf_val]...,) for (sf_key, sf_val) ∈ sf_map) + + open_stockflows::AbstractDict = Dict(sf_key => Open(sf_val[1], sf_val[2]...) for (sf_key, sf_val) ∈ sf_map) + + if RETURN_UWD # UWD might be a bit screwed up from the empty foot being first. + return apex(oapply(uwd, open_stockflows)), uwd + else + return apex(oapply(uwd, open_stockflows)) + end + +end + +end \ No newline at end of file diff --git a/test/Composition.jl b/test/Composition.jl new file mode 100644 index 00000000..86cce6a6 --- /dev/null +++ b/test/Composition.jl @@ -0,0 +1,112 @@ +using Test +using StockFlow +using StockFlow.Syntax +using StockFlow.Syntax.Composition +import StockFlow.Syntax.Composition: interpret_composition_notation + +@testset "Composition creates expected stock flows" begin + empty_sf = StockAndFlowF() + + + @test sfcompose(quote # composing no stock flows returns an empty stock flow. + () + end) == empty_sf + + @test sfcompose(empty_sf, quote + (sf,) + end) == empty_sf + + @test sfcompose((@stock_and_flow begin; :stocks; A; end;), (@stock_and_flow begin; :stocks; B; end;), quote + (sf1, sf2) + end) == (@stock_and_flow begin; :stocks; A; B; end;) # Combining without any composing + + @test sfcompose((@stock_and_flow begin; :stocks; A; end;), (@stock_and_flow begin; :stocks; A; end;), quote + (sf1, sf2) + end) == (@stock_and_flow begin; :stocks; A; A; end;) + + @test sfcompose((@stock_and_flow begin; :stocks; A; end;), (@stock_and_flow begin; :stocks; A; end;), quote + (sf1, sf2) + sf1, sf2 ^ A => () + end) == (@stock_and_flow begin; :stocks; A; end;) + + @test (sfcompose( + (@stock_and_flow begin + :stocks + A + B + + :dynamic_variables + v1 = A + B + + :sums + N = [A,B] + end), + (@stock_and_flow begin + :stocks + B + C + + :dynamic_variables + v2 = B + C + + :sums + N = [B,C] + end), + quote + (sfA, sfC) + sfA, sfC ^ B => N + end) + == + (@stock_and_flow begin + :stocks + A + B + C + + :dynamic_variables + v1 = A + B + v2 = B + C + + :sums + N = [A, B, C] + end) + ) + + +end + +@testset "interpret_composition_notation interprets arguments correctly" begin + # @test interpret_composition_notation(:(() ^ A => N)) == (Vector{Symbol}(), (@foot A => N)) + @test interpret_composition_notation(:(sf ^ A => N)) == ([:sf], (@foot A => N)) + @test interpret_composition_notation(:(sf1, sf2 ^ A => N)) == ([:sf1,:sf2], (@foot A => N)) + @test interpret_composition_notation(:(sf1, sf2 ^ A => N, A => NI)) == ([:sf1,:sf2], (@foot A => N, A => NI)) + @test interpret_composition_notation(:(sf1, sf2, sf3, sf4 ^ () => NI)) == ([:sf1, :sf2, :sf3, :sf4], (@foot () => NI)) + @test interpret_composition_notation(:(sf1, sf2 ^ L => ())) == ([:sf1,:sf2], (@foot L => ())) + + @test interpret_composition_notation(:(sf1, sf2 ^ () => ())) == ([:sf1,:sf2], (@foot () => ())) + +end + +@testset "invalid composition expressions fail" begin + @test_throws AssertionError interpret_composition_notation(:(B => C)) + @test_throws AssertionError interpret_composition_notation(:(A, B, C)) + @test_throws AssertionError interpret_composition_notation(:(A ^ B ^ C)) + @test_throws AssertionError interpret_composition_notation(:(A => B => C)) + @test_throws ErrorException interpret_composition_notation(:(A ^ B => C => D)) # caught by create_foot +end + +@testset "invalid sfcompose calls fail" begin + @test_throws AssertionError sfcompose((@stock_and_flow begin; :stocks; A; end;), (@stock_and_flow begin; :stocks; A; end;), quote + (sf1, sf2) + sf1, sf2 ^ () => () + end) # not allowed to map to empty + @test_throws AssertionError sfcompose((@stock_and_flow begin; :stocks; A; end;), (@stock_and_flow begin; :stocks; A; end;), quote + (sf1, sf2) + sf1 ^ A => () + sf2 ^ A => () + end) # not allowed to map to the same foot twice + @test_throws AssertionError sfcompose((@stock_and_flow begin; :stocks; A; end;), (@stock_and_flow begin; :stocks; A; end;), quote + (sf1,) + sf1 ^ A => () + end) # incorrect number of symbols on the first line in the quote +end \ No newline at end of file diff --git a/test/Syntax.jl b/test/Syntax.jl old mode 100644 new mode 100755 index 36df5324..b0b9c18a --- a/test/Syntax.jl +++ b/test/Syntax.jl @@ -4,6 +4,10 @@ using StockFlow using StockFlow.Syntax using StockFlow.Syntax: is_binop_or_unary, sum_variables, infix_expression_to_binops, fnone_value_or_vector, extract_function_name_and_args_expr, is_recursive_dyvar, create_foot +@testset "Composition DSL" begin + include("Composition.jl") +end + @testset "is_binop_or_unary recognises binops" begin @test is_binop_or_unary(:(a + b)) @test is_binop_or_unary(:(f(a, b))) @@ -337,5 +341,209 @@ end @test_throws Exception @eval @feet begin A => B; 1 => 2; end end +########################### + +@testset "infer_links works as expected" begin + # No prior mappings means no inferred mappings + @test (infer_links(StockAndFlowF(), StockAndFlowF(), Dict{Symbol, Vector{Int64}}(:S => [], :F => [], :SV => [], :P => [], :V => [])) + == Dict(:LS => [], :LSV => [], :LV => [], :I => [], :O => [], :LPV => [], :LVV => [])) + + # S: 1 -> 1 and SV: 1 -> 1 implies LS: 1 -> 1 + @test (infer_links( + (@stock_and_flow begin; :stocks; A; :sums; NA = [A]; end), + (@stock_and_flow begin; :stocks; B; :sums; NB = [B]; end), + Dict{Symbol, Vector{Int64}}(:S => [1], :F => [], :SV => [1], :P => [], :V => [])) + == Dict(:LS => [1], :LSV => [], :LV => [], :I => [], :O => [], :LPV => [], :LVV => [])) + + # annoying exanmple, required me to add code to disambiguate using position + # that is, vA = A + A, vA -> vB, A -> implies that the As in the vA definition map to the Bs in the vB definition + # But both As link to the same stock and dynamic variable so just looking at those isn't enough to figure out what it maps to. + # There will exist cases where it's impossible to tell - eg, when there exist multiple duplicate links, and some positions don't match up. + + # It does not currently look at the operator. You could therefore map vA = A + A -> vB = B * B + # I can see this being useful, actually, specifically when mapping between + and -, * and /, etc. Probably logs and powers too. + # Just need to be aware that it won't say it's invalid. + @test (infer_links( + (@stock_and_flow begin; :stocks; A; :dynamic_variables; vA = A + A; end), + (@stock_and_flow begin; :stocks; B; :dynamic_variables; vB = B + B; end), + Dict{Symbol, Vector{Int64}}(:S => [1], :F => [], :SV => [], :P => [], :V => [1])) + == Dict(:LS => [], :LSV => [], :LV => [2,2], :I => [], :O => [], :LPV => [], :LVV => [])) # If duplicate values, always map to end. + + @test (infer_links( + (@stock_and_flow begin; :stocks; A; :parameters; pA; :dynamic_variables; vA = A + pA; end), + (@stock_and_flow begin; :stocks; B; :parameters; pB; :dynamic_variables; vB = pB + B; end), + Dict{Symbol, Vector{Int64}}(:S => [1], :F => [], :SV => [], :P => [1], :V => [1])) + == Dict(:LS => [], :LSV => [], :LV => [1], :I => [], :O => [], :LPV => [1], :LVV => [])) + + @test (infer_links( + (@stock_and_flow begin + :stocks + S + I + R + + :parameters + p_inf + p_rec + + + :flows + S => f_StoI(p_inf * S) => I + I => f_ItoR(I * p_rec) => R + + :sums + N = [S,I,R] + NI = [I] + NS = [S,I,R] + end), + (@stock_and_flow begin + :stocks + pop + + :parameters + p_generic + + + :flows + pop => f_generic(p_generic * pop) => pop + + :sums + N = [pop] + NI = [pop] + NS = [pop] + end), + + Dict{Symbol, Vector{Int64}}(:S => [1,1,1], :F => [1,1], :SV => [1,2,3], :P => [1,1], :V => [1,1])) + == Dict(:LS => [1,3,1,2,3,1,3], :LSV => [], :LV => [1,1], :I => [1,1], :O => [1,1], :LPV => [1,1], :LVV => [])) + + +end + + +@testset "Applying flags can correctly find substring matches" begin + @test apply_flags(:f_, Set([:~]), Vector{Symbol}()) == [] + @test apply_flags(:f_, Set([:~]), [:f_death, :f_birth]) == [:f_death, :f_birth] + @test apply_flags(:NOMATCH, Set([:~]), [:f_death, :f_birth]) == [] + @test apply_flags(:f_birth, Set([:~]), [:f_death, :f_birth]) == [:f_birth] + @test apply_flags(:f_birth, Set{Symbol}(), [:f_death, :f_birth]) == [:f_birth] + + # Note, apply_flags is specifically meant to work on vectors without duplicates; the vector which is input are the keys of a dictionary. + # Regardless, the following will hold: + @test apply_flags(:f_birth, Set{Symbol}(), [:f_death, :f_birth, :f_birth, :f_birth]) == [:f_birth] + @test apply_flags(:f_birth, Set{Symbol}([:~]), [:f_death, :f_birth, :f_birth, :f_birth]) == [:f_birth, :f_birth, :f_birth] +end + + +@testset "substitute_symbols will correctly associate values of the two provided dictionaries based on user defined mappings" begin + # substitute_symbols(s::Dict{Symbol, Int}, t::Dict{Symbol, Int}, m::Vector{DSLArgument} ; use_flags::Bool=true)::Dict{Int, Int} + + + # Note, these dictionaries represent a vector where all the entries are unique, and the values are the original indices. + # So, both keys and values should be unique. + # For stratification, first dictionary is strata or aggregate, second is type, and the vector of DSLArgument are the user-defined maps. + # For homomorphism, first argument is src, second is dest, vector are user-defined maps. + @test substitute_symbols(Dict{Symbol, Int}(), Dict{Symbol, Int}(), Vector{DSLArgument}()) == Dict{Int, Int}() + @test substitute_symbols(Dict{Symbol, Int}(), Dict(:B => 2), Vector{DSLArgument}()) == Dict{Int, Int}() + + @test substitute_symbols(Dict(:A => 1), Dict(:B => 1), [DSLArgument(:A, :B, Set{Symbol}())]) == Dict(1 => 1) + @test substitute_symbols(Dict(:A1 => 1, :A2 => 2), Dict(:B => 1), [DSLArgument(:A1, :B, Set{Symbol}()), DSLArgument(:A2, :B, Set{Symbol}())]) == Dict(1 => 1, 2 => 1) + @test substitute_symbols(Dict(:A1 => 1), Dict(:B1 => 1, :B2 => 2), [DSLArgument(:A1, :B2, Set{Symbol}())]) == Dict(1 => 2) + + + @test substitute_symbols(Dict(:A1 => 1, :A2 => 2), Dict(:B1 => 1, :B2 => 2), [DSLArgument(:A, :B2, Set{Symbol}([:~]))]) == Dict(1 => 2, 2 => 2) + + # 1:100 + # 1:50 + # All multiples x of 14 below 100 go to x % 10 + 1 + @test (substitute_symbols(Dict(Symbol(i) => i for i ∈ 1:100), Dict(Symbol(-i) => i for i ∈ 1:50), [DSLArgument(Symbol(i), Symbol(-((i%10) + 1)), Set{Symbol}()) for i ∈ 1:100 if i % 14 == 0]) + == Dict(14 => 5, 28 => 9, 42 => 3, 56 => 7, 70 => 1, 84 => 5, 98 => 9)) + # Captures everything with a 7 as a digit + @test (substitute_symbols(Dict(Symbol(i) => i for i ∈ 1:100), Dict(Symbol(-i) => i for i ∈ 1:50), [DSLArgument(Symbol(7), Symbol(-1), Set{Symbol}([:~]))]) + == Dict(7 => 1, 17 => 1, 27 => 1, 37 => 1, 47 => 1, 57 => 1, 67 => 1, 70 => 1, 71 => 1, 72 => 1, 73 => 1, 74 => 1, 75 => 1, 76 => 1, 77 => 1, 78 => 1, 79 => 1, 87 => 1, 97 => 1)) + + @test substitute_symbols(Dict(Symbol("~") => 1), Dict(:R => 1), [DSLArgument(Symbol("~"), :R, Set([:~]))], ; use_flags = false) == Dict(1 => 1) # Note, the Set([:~]) is ignored because use_flags is false + +end + + +@testset "non-natural transformations fail infer_links" begin + + # Map both dynamic variables to the same + # Obviously, this will fail, as the new dynamic variable needs a LVV and one LV, but instead has two LV + @test_throws KeyError (infer_links( + (@stock_and_flow begin + :stocks + A + + :dynamic_variables + v1 = A + A + v2 = v1 + A + end), + (@stock_and_flow begin + :stocks + A + + :dynamic_variables + v1 = A + A + end), + Dict{Symbol, Vector{Int64}}(:S => [1], :V => [1,1]))) + + + # Mapping it all to I + + # This one fails when trying to figure out the inflow. Stock maps to 2, and flow maps to 2, + # But inflows on the target have (1,2) and (2,3) + + # This also wouldn't work if we tried mapping flow to 1 instead. Outflows expect 1,1 or 2,2, + # so it fails on (2,1). + @test_throws KeyError (infer_links( + (@stock_and_flow begin + :stocks + pop + + :parameters + p_generic + + + :flows + pop => f_generic(p_generic * pop) => pop + + :sums + N = [pop] + NI = [pop] + NS = [pop] + end), + (@stock_and_flow begin + :stocks + S + I + R + + :parameters + p_inf + p_rec + + + :flows + S => f_StoI(p_inf * S) => I + I => f_ItoR(I * p_rec) => R + + :sums + N = [S,I,R] + NI = [I] + NS = [S,I,R] + end), + Dict{Symbol, Vector{Int64}}(:S => [2], :F => [2], :SV => [1,2,3], :P => [2], :V => [2]))) + +end + +@testset "Applying flags throws on invalid inputs" begin + @test_throws ErrorException apply_flags(:f_, Set([:+]), [:f_death, :f_birth]) # fails because :+ is not a defined operation + @test_throws ErrorException apply_flags(:f_birth, Set([:~, :+]), [:f_death, :f_birth]) # also fails for same reason + + @test_throws AssertionError apply_flags(:NOMATCH, Set{Symbol}(), Vector{Symbol}()) # fails because it's not looking for substrings, and :NOMATCH isn't in the list of options. + @test_throws AssertionError apply_flags(:NOMATCH, Set{Symbol}(), [:nomatch]) # same reason + +end