diff --git a/docs/src/user_interface/truncations.md b/docs/src/user_interface/truncations.md index cdcc8b589..b108470cb 100644 --- a/docs/src/user_interface/truncations.md +++ b/docs/src/user_interface/truncations.md @@ -8,7 +8,18 @@ CollapsedDocStrings = true Currently, truncations are supported through the following different methods: ```@docs; canonical=false +notrunc truncrank trunctol truncabove +truncerror +``` + +It is additionally possible to combine truncation strategies by making use of the `&` operator. +For example, truncating to a maximal dimension `10`, and discarding all values below `1e-6` would be achieved by: + +```julia +maxdim = 10 +tol = 1e-6 +combined_trunc = truncrank(maxdim) & trunctol(tol) ``` diff --git a/src/MatrixAlgebraKit.jl b/src/MatrixAlgebraKit.jl index a8b094fb8..a2fe9a086 100644 --- a/src/MatrixAlgebraKit.jl +++ b/src/MatrixAlgebraKit.jl @@ -37,7 +37,7 @@ export LAPACK_HouseholderQR, LAPACK_HouseholderLQ, CUSOLVER_HouseholderQR, CUSOLVER_QRIteration, CUSOLVER_SVDPolar, CUSOLVER_Jacobi, CUSOLVER_Randomized, CUSOLVER_DivideAndConquer, ROCSOLVER_HouseholderQR, ROCSOLVER_QRIteration, ROCSOLVER_Jacobi, ROCSOLVER_DivideAndConquer, ROCSOLVER_Bisection, DiagonalAlgorithm -export truncrank, trunctol, truncabove, TruncationKeepSorted, TruncationKeepFiltered +export truncrank, trunctol, truncabove, TruncationKeepSorted, TruncationKeepFiltered, truncerror VERSION >= v"1.11.0-DEV.469" && eval(Expr(:public, :default_algorithm, :findtruncated, :findtruncated_sorted, @@ -55,6 +55,7 @@ include("common/gauge.jl") include("yalapack.jl") include("algorithms.jl") include("interface/decompositions.jl") +include("interface/truncation.jl") include("interface/qr.jl") include("interface/lq.jl") include("interface/svd.jl") diff --git a/src/algorithms.jl b/src/algorithms.jl index 2dc94e01d..abce4097f 100644 --- a/src/algorithms.jl +++ b/src/algorithms.jl @@ -131,6 +131,71 @@ If this is not possible, for example when the output size is not known a priori this function may return `nothing`. """ initialize_output +# Truncation strategy +# ------------------- +""" + abstract type TruncationStrategy end + +Supertype to denote different strategies for truncated decompositions that are implemented via post-truncation. + +See also [`truncate!`](@ref) +""" +abstract type TruncationStrategy end + +@doc """ + MatrixAlgebraKit.select_truncation(trunc) + +Construct a [`TruncationStrategy`](@ref) from the given `NamedTuple` of keywords or input strategy. +""" select_truncation + +function select_truncation(trunc) + if isnothing(trunc) + return NoTruncation() + elseif trunc isa NamedTuple + return TruncationStrategy(; trunc...) + elseif trunc isa TruncationStrategy + return trunc + else + return throw(ArgumentError("Unknown truncation strategy: $trunc")) + end +end + +@doc """ + MatrixAlgebraKit.findtruncated(values::AbstractVector, strategy::TruncationStrategy) + +Generic interface for finding truncated values of the spectrum of a decomposition +based on the `strategy`. The output should be a collection of indices specifying +which values to keep. `MatrixAlgebraKit.findtruncated` is used inside of the default +implementation of [`truncate!`](@ref) to perform the truncation. It does not assume that the +values are sorted. For a version that assumes the values are reverse sorted (which is the +standard case for SVD) see [`MatrixAlgebraKit.findtruncated_sorted`](@ref). +""" findtruncated + +@doc """ + MatrixAlgebraKit.findtruncated_sorted(values::AbstractVector, strategy::TruncationStrategy) + +Like [`MatrixAlgebraKit.findtruncated`](@ref) but assumes that the values are real and +sorted in descending order, as typically obtained by the SVD. This assumption is not +checked, and this is used in the default implementation of [`svd_trunc!`](@ref). +""" findtruncated_sorted + +""" + TruncatedAlgorithm(alg::AbstractAlgorithm, trunc::TruncationAlgorithm) + +Generic wrapper type for algorithms that consist of first using `alg`, followed by a +truncation through `trunc`. +""" +struct TruncatedAlgorithm{A,T} <: AbstractAlgorithm + alg::A + trunc::T +end + +@doc """ + truncate!(f, out, strategy::TruncationStrategy) + +Generic interface for post-truncating a decomposition, specified in `out`. +""" truncate! + # Utility macros # -------------- diff --git a/src/implementations/truncation.jl b/src/implementations/truncation.jl index 0baecdbe7..9f7d07193 100644 --- a/src/implementations/truncation.jl +++ b/src/implementations/truncation.jl @@ -1,155 +1,6 @@ -""" - abstract type TruncationStrategy end - -Supertype to denote different strategies for truncated decompositions that are implemented via post-truncation. - -See also [`truncate!`](@ref) -""" -abstract type TruncationStrategy end - -function TruncationStrategy(; atol=nothing, rtol=nothing, maxrank=nothing) - if isnothing(maxrank) && isnothing(atol) && isnothing(rtol) - return NoTruncation() - elseif isnothing(maxrank) - atol = @something atol 0 - rtol = @something rtol 0 - return TruncationKeepAbove(atol, rtol) - else - if isnothing(atol) && isnothing(rtol) - return truncrank(maxrank) - else - atol = @something atol 0 - rtol = @something rtol 0 - return truncrank(maxrank) & TruncationKeepAbove(atol, rtol) - end - end -end - -""" - NoTruncation() - -Trivial truncation strategy that keeps all values, mostly for testing purposes. -""" -struct NoTruncation <: TruncationStrategy end - -function select_truncation(trunc) - if isnothing(trunc) - return NoTruncation() - elseif trunc isa NamedTuple - return TruncationStrategy(; trunc...) - elseif trunc isa TruncationStrategy - return trunc - else - return throw(ArgumentError("Unknown truncation strategy: $trunc")) - end -end - -# TODO: how do we deal with sorting/filters that treat zeros differently -# since these are implicitly discarded by selecting compact/full - -""" - TruncationKeepSorted(howmany::Int, by::Function, rev::Bool) - -Truncation strategy to keep the first `howmany` values when sorted according to `by` in increasing (decreasing) order if `rev` is false (true). -""" -struct TruncationKeepSorted{F} <: TruncationStrategy - howmany::Int - by::F - rev::Bool -end - -""" - TruncationKeepFiltered(filter::Function) - -Truncation strategy to keep the values for which `filter` returns true. -""" -struct TruncationKeepFiltered{F} <: TruncationStrategy - filter::F -end - -struct TruncationKeepAbove{T<:Real,F} <: TruncationStrategy - atol::T - rtol::T - p::Int - by::F -end -function TruncationKeepAbove(; atol::Real, rtol::Real, p::Int=2, by=abs) - return TruncationKeepAbove(atol, rtol, p, by) -end -function TruncationKeepAbove(atol::Real, rtol::Real, p::Int=2, by=abs) - return TruncationKeepAbove(promote(atol, rtol)..., p, by) -end - -struct TruncationKeepBelow{T<:Real,F} <: TruncationStrategy - atol::T - rtol::T - p::Int - by::F -end -function TruncationKeepBelow(; atol::Real, rtol::Real, p::Int=2, by=abs) - return TruncationKeepBelow(atol, rtol, p, by) -end -function TruncationKeepBelow(atol::Real, rtol::Real, p::Int=2, by=abs) - return TruncationKeepBelow(promote(atol, rtol)..., p, by) -end - -# TODO: better names for these functions of the above types -""" - truncrank(howmany::Int; by=abs, rev=true) - -Truncation strategy to keep the first `howmany` values when sorted according to `by` or the last `howmany` if `rev` is true. -""" -truncrank(howmany::Int; by=abs, rev=true) = TruncationKeepSorted(howmany, by, rev) - -""" - trunctol(atol::Real; by=abs) - -Truncation strategy to discard the values that are smaller than `atol` according to `by`. -""" -trunctol(atol; by=abs) = TruncationKeepFiltered(≥(atol) ∘ by) - -""" - truncabove(atol::Real; by=abs) - -Truncation strategy to discard the values that are larger than `atol` according to `by`. -""" -truncabove(atol; by=abs) = TruncationKeepFiltered(≤(atol) ∘ by) - -""" - TruncationIntersection(trunc::TruncationStrategy, truncs::TruncationStrategy...) - -Composition of multiple truncation strategies, keeping values common between them. -""" -struct TruncationIntersection{T<:Tuple{Vararg{TruncationStrategy}}} <: - TruncationStrategy - components::T -end -function TruncationIntersection(trunc::TruncationStrategy, truncs::TruncationStrategy...) - return TruncationIntersection((trunc, truncs...)) -end - -function Base.:&(trunc1::TruncationStrategy, trunc2::TruncationStrategy) - return TruncationIntersection((trunc1, trunc2)) -end -function Base.:&(trunc1::TruncationIntersection, trunc2::TruncationIntersection) - return TruncationIntersection((trunc1.components..., trunc2.components...)) -end -function Base.:&(trunc1::TruncationIntersection, trunc2::TruncationStrategy) - return TruncationIntersection((trunc1.components..., trunc2)) -end -function Base.:&(trunc1::TruncationStrategy, trunc2::TruncationIntersection) - return TruncationIntersection((trunc1, trunc2.components...)) -end - # truncate! # --------- # Generic implementation: `findtruncated` followed by indexing -@doc """ - truncate!(f, out, strategy::TruncationStrategy) - -Generic interface for post-truncating a decomposition, specified in `out`. -""" truncate! -# TODO: should we return a view? function truncate!(::typeof(svd_trunc!), (U, S, Vᴴ), strategy::TruncationStrategy) ind = findtruncated_sorted(diagview(S), strategy) return U[:, ind], Diagonal(diagview(S)[ind]), Vᴴ[ind, :] @@ -178,32 +29,8 @@ end # findtruncated # ------------- # specific implementations for finding truncated values -@doc """ - MatrixAlgebraKit.findtruncated(values::AbstractVector, strategy::TruncationStrategy) - -Generic interface for finding truncated values of the spectrum of a decomposition -based on the `strategy`. The output should be a collection of indices specifying -which values to keep. `MatrixAlgebraKit.findtruncated` is used inside of the default -implementation of [`truncate!`](@ref) to perform the truncation. It does not assume that the -values are sorted. For a version that assumes the values are reverse sorted (which is the -standard case for SVD) see [`MatrixAlgebraKit.findtruncated_sorted`](@ref). -""" findtruncated - -@doc """ - MatrixAlgebraKit.findtruncated_sorted(values::AbstractVector, strategy::TruncationStrategy) - -Like [`MatrixAlgebraKit.findtruncated`](@ref) but assumes that the values are sorted in reverse order. -They are assumed to be sorted in a way that is consistent with the truncation strategy, -which generally means they are sorted by absolute value but some truncation strategies allow -customizing that. However, note that this assumption is not checked, so passing values that are not sorted -in the correct way can silently give unexpected results. This is used in the default implementation of -[`svd_trunc!`](@ref). -""" findtruncated_sorted - findtruncated(values::AbstractVector, ::NoTruncation) = Colon() -# TODO: this may also permute the eigenvalues, decide if we want to allow this or not -# can be solved by going to simply sorting the resulting `ind` function findtruncated(values::AbstractVector, strategy::TruncationKeepSorted) howmany = min(strategy.howmany, length(values)) return partialsortperm(values, 1:howmany; by=strategy.by, rev=strategy.rev) @@ -243,19 +70,40 @@ function findtruncated(values::AbstractVector, strategy::TruncationIntersection) inds = map(Base.Fix1(findtruncated, values), strategy.components) return intersect(inds...) end +function findtruncated_sorted(values::AbstractVector, strategy::TruncationIntersection) + inds = map(Base.Fix1(findtruncated_sorted, values), strategy.components) + return intersect(inds...) +end -# Generic fallback. -function findtruncated_sorted(values::AbstractVector, strategy::TruncationStrategy) - return findtruncated(values, strategy) +function findtruncated(values::AbstractVector, strategy::TruncationError) + I = sortperm(values; by=abs, rev=true) + I′ = _truncerr_impl(values, I, strategy) + return I[I′] end +function findtruncated_sorted(values::AbstractVector, strategy::TruncationError) + I = eachindex(values) + I′ = _truncerr_impl(values, I, strategy) + return I[I′] +end +function _truncerr_impl(values::AbstractVector, I, strategy::TruncationError) + Nᵖ = sum(Base.Fix2(^, strategy.p) ∘ abs, values) + ϵᵖ = max(strategy.atol^strategy.p, strategy.rtol^strategy.p * Nᵖ) + ϵᵖ ≥ Nᵖ && return Base.OneTo(0) -""" - TruncatedAlgorithm(alg::AbstractAlgorithm, trunc::TruncationAlgorithm) + truncerrᵖ = zero(real(eltype(values))) + rank = length(values) + for i in reverse(I) + truncerrᵖ += abs(values[i])^strategy.p + if truncerrᵖ ≥ ϵᵖ + break + else + rank -= 1 + end + end + return Base.OneTo(rank) +end -Generic wrapper type for algorithms that consist of first using `alg`, followed by a -truncation through `trunc`. -""" -struct TruncatedAlgorithm{A,T} <: AbstractAlgorithm - alg::A - trunc::T +# Generic fallback +function findtruncated_sorted(values::AbstractVector, strategy::TruncationStrategy) + return findtruncated(values, strategy) end diff --git a/src/interface/truncation.jl b/src/interface/truncation.jl new file mode 100644 index 000000000..8710570df --- /dev/null +++ b/src/interface/truncation.jl @@ -0,0 +1,169 @@ +""" + TruncationStrategy(; kwargs...) + +Select a truncation strategy based on the provided keyword arguments. + +## Keyword arguments +- `atol=nothing` : Absolute tolerance for the truncation +- `rtol=nothing` : Relative tolerance for the truncation +- `maxrank=nothing` : Maximal rank for the truncation +""" +function TruncationStrategy(; atol=nothing, rtol=nothing, maxrank=nothing) + if isnothing(maxrank) && isnothing(atol) && isnothing(rtol) + return NoTruncation() + elseif isnothing(maxrank) + atol = @something atol 0 + rtol = @something rtol 0 + return TruncationKeepAbove(atol, rtol) + else + if isnothing(atol) && isnothing(rtol) + return truncrank(maxrank) + else + atol = @something atol 0 + rtol = @something rtol 0 + return truncrank(maxrank) & TruncationKeepAbove(atol, rtol) + end + end +end + +""" + NoTruncation() + +Trivial truncation strategy that keeps all values, mostly for testing purposes. +See also [`notrunc()`](@ref). +""" +struct NoTruncation <: TruncationStrategy end + +""" + notrunc() + +Truncation strategy that does nothing, and keeps all the values. +""" +notrunc() = NoTruncation() + +""" + TruncationKeepSorted(howmany::Int, by::Function, rev::Bool) + +Truncation strategy to keep the first `howmany` values when sorted according to `by` in increasing (decreasing) order if `rev` is false (true). +See also [`truncrank`](@ref). +""" +struct TruncationKeepSorted{F} <: TruncationStrategy + howmany::Int + by::F + rev::Bool +end + +""" + truncrank(howmany::Int; by=abs, rev=true) + +Truncation strategy to keep the first `howmany` values when sorted according to `by` or the last `howmany` if `rev` is true. +""" +truncrank(howmany::Int; by=abs, rev=true) = TruncationKeepSorted(howmany, by, rev) + +""" + TruncationKeepFiltered(filter::Function) + +Truncation strategy to keep the values for which `filter` returns true. +""" +struct TruncationKeepFiltered{F} <: TruncationStrategy + filter::F +end + +""" + trunctol(val::Real; by=abs) + +Truncation strategy to discard the values that are smaller than `val` according to `by`. +""" +trunctol(val::Real; by=abs) = TruncationKeepFiltered(≥(val) ∘ by) + +""" + truncabove(val::Real; by=abs) + +Truncation strategy to discard the values that are larger than `val` according to `by`. +""" +truncabove(val::Real; by=abs) = TruncationKeepFiltered(≤(val) ∘ by) + +struct TruncationKeepAbove{T<:Real,P<:Real,F} <: TruncationStrategy + atol::T + rtol::T + p::P + by::F +end +function TruncationKeepAbove(; atol::Real, rtol::Real, p::Real=2, by=abs) + return TruncationKeepAbove(atol, rtol, p, by) +end +function TruncationKeepAbove(atol::Real, rtol::Real, p::Real=2, by=abs) + return TruncationKeepAbove(promote(atol, rtol)..., p, by) +end + +""" + TruncationKeepBelow(; atol::Real, rtol::Real, p=2, by=abs) + +Truncation strategy to discard the values that are smaller than the norm of the values. +""" +struct TruncationKeepBelow{T<:Real,P<:Real,F} <: TruncationStrategy + atol::T + rtol::T + p::P + by::F +end +function TruncationKeepBelow(; atol::Real, rtol::Real, p::Real=2, by=abs) + return TruncationKeepBelow(atol, rtol, p, by) +end +function TruncationKeepBelow(atol::Real, rtol::Real, p::Real=2, by=abs) + return TruncationKeepBelow(promote(atol, rtol)..., p, by) +end + +""" + TruncationIntersection(trunc::TruncationStrategy, truncs::TruncationStrategy...) + +Composition of multiple truncation strategies, keeping values common between them. +""" +struct TruncationIntersection{T<:Tuple{Vararg{TruncationStrategy}}} <: + TruncationStrategy + components::T +end +function TruncationIntersection(trunc::TruncationStrategy, truncs::TruncationStrategy...) + return TruncationIntersection((trunc, truncs...)) +end + +function Base.:&(trunc1::TruncationStrategy, trunc2::TruncationStrategy) + return TruncationIntersection((trunc1, trunc2)) +end +function Base.:&(trunc1::TruncationIntersection, trunc2::TruncationIntersection) + return TruncationIntersection((trunc1.components..., trunc2.components...)) +end +function Base.:&(trunc1::TruncationIntersection, trunc2::TruncationStrategy) + return TruncationIntersection((trunc1.components..., trunc2)) +end +function Base.:&(trunc1::TruncationStrategy, trunc2::TruncationIntersection) + return TruncationIntersection((trunc1, trunc2.components...)) +end + +""" + TruncationError(; atol::Real, rtol::Real, p::Real) + +Truncation strategy to discard values until the error caused by the discarded values exceeds some tolerances. +See also [`truncerror`](@ref). +""" +struct TruncationError{T<:Real,P<:Real} <: TruncationStrategy + atol::T + rtol::T + p::P +end +function TruncationError(; atol::Real, rtol::Real, p::Real=2) + return TruncationError(atol, rtol, p) +end +function TruncationError(atol::Real, rtol::Real, p::Real=2) + return TruncationError(promote(atol, rtol)..., p) +end + +""" + truncerror(; atol::Real=0, rtol::Real=0, p::Real=2) + +Create a truncation strategy for truncating such that the error in the factorization +is smaller than `max(atol, rtol * norm)`, where the error is determined using the `p`-norm. +""" +function truncerror(; atol::Real=0, rtol::Real=0, p::Real=2) + return TruncationError(promote(atol, rtol)..., p) +end diff --git a/test/eig.jl b/test/eig.jl index d4d8dcf27..b3e8c600a 100644 --- a/test/eig.jl +++ b/test/eig.jl @@ -3,7 +3,7 @@ using Test using TestExtras using StableRNGs using LinearAlgebra: Diagonal -using MatrixAlgebraKit: TruncatedAlgorithm, diagview +using MatrixAlgebraKit: TruncatedAlgorithm, diagview, norm const BLASFloats = (Float32, Float64, ComplexF32, ComplexF64) @@ -53,10 +53,18 @@ end @test length(diagview(D2)) == r @test A * V2 ≈ V2 * D2 + s = 1 - sqrt(eps(real(T))) + trunc = truncerror(; atol=s * norm(@view(D₀[r:end]), 1), p=1) + D3, V3 = @constinferred eig_trunc(A; alg, trunc) + @test length(diagview(D3)) == r + @test A * V3 ≈ V3 * D3 + # trunctol keeps order, truncrank might not # test for same subspace @test V1 * ((V1' * V1) \ (V1' * V2)) ≈ V2 @test V2 * ((V2' * V2) \ (V2' * V1)) ≈ V1 + @test V1 * ((V1' * V1) \ (V1' * V3)) ≈ V3 + @test V3 * ((V3' * V3) \ (V3' * V1)) ≈ V1 end end @@ -70,6 +78,10 @@ end D2, V2 = @constinferred eig_trunc(A; alg) @test diagview(D2) ≈ diagview(D)[1:2] rtol = sqrt(eps(real(T))) @test_throws ArgumentError eig_trunc(A; alg, trunc=(; maxrank=2)) + + alg = TruncatedAlgorithm(LAPACK_Simple(), truncerror(; atol=0.2, p=1)) + D3, V3 = @constinferred eig_trunc(A; alg) + @test diagview(D3) ≈ diagview(D)[1:2] rtol = sqrt(eps(real(T))) end @testset "eig for Diagonal{$T}" for T in BLASFloats diff --git a/test/eigh.jl b/test/eigh.jl index 59f4050ee..9b8811607 100644 --- a/test/eigh.jl +++ b/test/eigh.jl @@ -3,7 +3,7 @@ using Test using TestExtras using StableRNGs using LinearAlgebra: LinearAlgebra, Diagonal, I -using MatrixAlgebraKit: TruncatedAlgorithm, diagview +using MatrixAlgebraKit: TruncatedAlgorithm, diagview, norm const BLASFloats = (Float32, Float64, ComplexF32, ComplexF64) @@ -58,9 +58,17 @@ end @test isisometry(V2) @test A * V2 ≈ V2 * D2 + s = 1 - sqrt(eps(real(T))) + trunc = truncerror(; atol=s * norm(@view(D₀[r:end]), 1), p=1) + D3, V3 = @constinferred eigh_trunc(A; alg, trunc) + @test length(diagview(D3)) == r + @test A * V3 ≈ V3 * D3 + # test for same subspace @test V1 * (V1' * V2) ≈ V2 @test V2 * (V2' * V1) ≈ V1 + @test V1 * (V1' * V3) ≈ V3 + @test V3 * (V3' * V1) ≈ V1 end end @@ -75,6 +83,10 @@ end D2, V2 = @constinferred eigh_trunc(A; alg) @test diagview(D2) ≈ diagview(D)[1:2] rtol = sqrt(eps(real(T))) @test_throws ArgumentError eigh_trunc(A; alg, trunc=(; maxrank=2)) + + alg = TruncatedAlgorithm(LAPACK_QRIteration(), truncerror(; atol=0.2)) + D3, V3 = @constinferred eigh_trunc(A; alg) + @test diagview(D3) ≈ diagview(D)[1:2] rtol = sqrt(eps(real(T))) end @testset "eigh for Diagonal{$T}" for T in BLASFloats diff --git a/test/svd.jl b/test/svd.jl index 5e7d65ecf..f3a1a4cba 100644 --- a/test/svd.jl +++ b/test/svd.jl @@ -2,7 +2,7 @@ using MatrixAlgebraKit using Test using TestExtras using StableRNGs -using LinearAlgebra: LinearAlgebra, Diagonal, I, isposdef +using LinearAlgebra: LinearAlgebra, Diagonal, I, isposdef, norm using MatrixAlgebraKit: TruncatedAlgorithm, TruncationKeepAbove, diagview, isisometry const BLASFloats = (Float32, Float64, ComplexF32, ComplexF64) @@ -120,6 +120,13 @@ end @test U1 ≈ U2 @test S1 ≈ S2 @test V1ᴴ ≈ V2ᴴ + + trunc = truncerror(; atol = s * norm(@view(S₀[(r + 1):end]))) + U3, S3, V3ᴴ = @constinferred svd_trunc(A; alg, trunc) + @test length(S3.diag) == r + @test U1 ≈ U3 + @test S1 ≈ S3 + @test V1ᴴ ≈ V3ᴴ end end end diff --git a/test/truncate.jl b/test/truncate.jl index 73c9aff39..a2701c2a0 100644 --- a/test/truncate.jl +++ b/test/truncate.jl @@ -69,4 +69,8 @@ using MatrixAlgebraKit: NoTruncation, TruncationIntersection, TruncationKeepAbov TruncationKeepBelow(0.2, 0)) @test @constinferred(findtruncated(values, strategy)) == [1] end + for strategy in (truncerror(; atol=0.2, rtol=0),) + @test issetequal(@constinferred(findtruncated(values, strategy)), 2:5) + @test @constinferred(findtruncated_sorted(sort(values; by=abs, rev=true), strategy)) == 1:4 + end end