From 2a921a581e7a5410bb113455a28dd738103bbcb0 Mon Sep 17 00:00:00 2001 From: DrChainsaw Date: Sun, 30 Jun 2024 19:51:57 +0200 Subject: [PATCH] Add graphsummary and make it the defaul show for CompGraphs --- Project.toml | 1 + docs/make.jl | 6 +- docs/src/reference/simple/graph.md | 1 + src/NaiveNASlib.jl | 3 +- src/api/Extend.jl | 2 +- src/api/vertex.jl | 21 +++++- src/compgraph.jl | 2 +- src/mutation/vertex.jl | 2 + src/prettyprint.jl | 111 +++++++++++++++++++++++++++++ src/vertex.jl | 6 +- test/examples/quicktutorial.jl | 66 ++++++++++------- test/prettyprint.jl | 83 +++++++++++++++++++++ 12 files changed, 269 insertions(+), 35 deletions(-) diff --git a/Project.toml b/Project.toml index 682d18c2..f57196a0 100644 --- a/Project.toml +++ b/Project.toml @@ -8,6 +8,7 @@ HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +PrettyTables = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" Reexport = "189a3867-3050-52da-a836-e630ba90ab69" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" diff --git a/docs/make.jl b/docs/make.jl index c4bb478d..b6086ef2 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -2,15 +2,15 @@ using Documenter, Literate, NaiveNASlib, NaiveNASlib.Advanced, NaiveNASlib.Exten const nndir = joinpath(dirname(pathof(NaiveNASlib)), "..") -function literate_example(sourcefile; rootdir=nndir, sourcedir = "test/examples", destdir="docs/src/examples") - fullpath = Literate.markdown(joinpath(rootdir, sourcedir, sourcefile), joinpath(rootdir, destdir); flavor=Literate.DocumenterFlavor(), mdstrings=true, codefence="````julia" => "````") +function literate_example(sourcefile; rootdir=nndir, sourcedir = "test/examples", destdir="docs/src/examples", kwargs...) + fullpath = Literate.markdown(joinpath(rootdir, sourcedir, sourcefile), joinpath(rootdir, destdir); flavor=Literate.DocumenterFlavor(), mdstrings=true, kwargs...) dirs = splitpath(fullpath) srcind = findfirst(==("src"), dirs) joinpath(dirs[srcind+1:end]...) end quicktutorial = literate_example("quicktutorial.jl") -advancedtutorial = literate_example("advancedtutorial.jl") +advancedtutorial = literate_example("advancedtutorial.jl"; codefence="````julia" => "````") makedocs( sitename="NaiveNASlib", root = joinpath(nndir, "docs"), diff --git a/docs/src/reference/simple/graph.md b/docs/src/reference/simple/graph.md index fc6ec84e..8c468965 100644 --- a/docs/src/reference/simple/graph.md +++ b/docs/src/reference/simple/graph.md @@ -7,4 +7,5 @@ outputs(::CompGraph) vertices nvertices findvertices +graphsummary ``` \ No newline at end of file diff --git a/src/NaiveNASlib.jl b/src/NaiveNASlib.jl index 412007f3..3ad42f76 100644 --- a/src/NaiveNASlib.jl +++ b/src/NaiveNASlib.jl @@ -11,9 +11,10 @@ using JuMP: @variable, @constraint, @objective, @expression, MOI, MOI.INFEASIBLE import HiGHS import Functors using Functors: @functor, functor +import PrettyTables # Computation graph -export CompGraph, nvertices, vertices, findvertices, inputs, outputs, name +export CompGraph, nvertices, vertices, findvertices, inputs, outputs, name, graphsummary # Vertex size operations export nin, nout, Δnin!, Δnout!, Δsize!, relaxed diff --git a/src/api/Extend.jl b/src/api/Extend.jl index 4a0a2588..074d5350 100644 --- a/src/api/Extend.jl +++ b/src/api/Extend.jl @@ -16,6 +16,6 @@ using Reexport: @reexport @reexport using ..NaiveNASlib: AbstractAlignSizeStrategy @reexport using ..NaiveNASlib: AbstractConnectStrategy -@reexport using ..NaiveNASlib: base, parselect, vertex +@reexport using ..NaiveNASlib: base, parselect, vertex, op end \ No newline at end of file diff --git a/src/api/vertex.jl b/src/api/vertex.jl index 9db77fd5..348f8eb0 100644 --- a/src/api/vertex.jl +++ b/src/api/vertex.jl @@ -152,6 +152,12 @@ julia> name(v) """ invariantvertex(args...; traitdecoration=identity) = vertex(traitdecoration(SizeInvariant()), args...) +struct Concat{D} + dims::D +end +(c::Concat)(x...) = cat(x...; dims=c.dims) +Base.show(io::IO, c::Concat) = print(io, "cat(x..., dims=", c.dims, ')') + """ conc(v::AbstractVertex, vs::AbstractVertex...; dims, traitdecoration=identity, outwrap=identity) conc(vname::AbstractString, v::AbstractVertex, vs::AbstractVertex...; dims, traitdecoration=identity, outwrap=identity) @@ -201,10 +207,10 @@ julia> v([1], [2, 3], [4, 5, 6]) ``` """ function conc(v::AbstractVertex, vs::AbstractVertex...; dims, traitdecoration=identity, outwrap=identity) - vertex(traitdecoration(SizeStack()), outwrap((x...) -> cat(x..., dims=dims)), v, vs...) + vertex(traitdecoration(SizeStack()), outwrap(Concat(dims)), v, vs...) end function conc(vname::AbstractString, v::AbstractVertex, vs::AbstractVertex...; dims, traitdecoration=identity, outwrap=identity) - vertex(traitdecoration(SizeStack()), vname, outwrap((x...) -> cat(x..., dims=dims)), v, vs...) + vertex(traitdecoration(SizeStack()), vname, outwrap(Concat(dims)), v, vs...) end @@ -238,10 +244,19 @@ Shortcut for [`VertexConf(;outwrap=o)`](@ref). outwrapconf(o) = VertexConf(outwrap=o) VertexConf(;traitdecoration = identity, outwrap = identity)= VertexConf(traitdecoration, outwrap) +struct ElementWiseOp{F} + op::F +end +(e::ElementWiseOp)(x...) = e.op.(x...) +function Base.show(io::IO, e::ElementWiseOp) + show(io, e.op) + print(io, " (element wise)") +end + # Common wiring for all elementwise operations function elemwise(op, conf::VertexConf, vs::AbstractVertex...) all(vi -> nout(vi) == nout(vs[1]), vs) || throw(DimensionMismatch("nout of all vertices input to elementwise vertex must be equal! Got $(nout.(vs))")) - invariantvertex(conf.outwrap((x...) -> op.(x...)), vs...; conf.traitdecoration) + invariantvertex(conf.outwrap(ElementWiseOp(op)), vs...; conf.traitdecoration) end diff --git a/src/compgraph.jl b/src/compgraph.jl index 70f4389e..50291b37 100644 --- a/src/compgraph.jl +++ b/src/compgraph.jl @@ -208,7 +208,7 @@ julia> vertices(graph) vertices(g::CompGraph{<:Any, <:Tuple}) = unique(mapfoldl(ancestors, vcat, outputs(g))) vertices(g::CompGraph{<:Any, <:AbstractVertex}) = ancestors(g.outputs) -## Non-public stuff to compute the CompGraph in a Zygote (and hopefully generally reverse-AD friendly) manner +## Non-public stuff to compute the CompGraph in a Zygote (and hopefully generally reverse-AD) friendly manner compute_graph(memo, v::AbstractVertex) = last(output_with_memo(memo, v)) compute_graph(memo, vs::Tuple) = last(_calc_outs(memo, vs)) diff --git a/src/mutation/vertex.jl b/src/mutation/vertex.jl index 2561ee40..9c6f06bc 100644 --- a/src/mutation/vertex.jl +++ b/src/mutation/vertex.jl @@ -6,6 +6,8 @@ Return the vertex wrapped in `v` (if any). """ function base(::AbstractVertex) end +op(v::AbstractVertex) = op(base(v)) + """ OutputsVertex diff --git a/src/prettyprint.jl b/src/prettyprint.jl index 72c72ef0..a3640507 100644 --- a/src/prettyprint.jl +++ b/src/prettyprint.jl @@ -1,3 +1,114 @@ +Base.show(g::CompGraph, args...; kwargs...) = show(stdout, g, args...; kwargs...) +function Base.show(io::IO, g::CompGraph, args...; kwargs...) + # Don't print the summary table if we are printing some iterable (as indicated by presence of :SHOWN_SET) + haskey(io, :SHOWN_SET) && return print(io, "CompGraph(", nvertices(g)," vertices)") + graphsummary(io, g, args...; title="CompGraph with graphsummary:", kwargs...) +end + +""" + graphsummary([io], graph, extracolumns...; [inputhl], [outputhl], kwargs...) + +Prints a summary table of `graph` to `io` using `PrettyTables.pretty_table`. + +Extra columns can be added to the table by providing any number of `extracolumns` which can be one of the following: +* a function (or any callable object) which takes a vertex as input and returns the column content +* a `Pair` where the first element is the column name and the other element is what previous bullet describes + +The keyword arguments `inputhl` (default `crayon"fg:black bg:249"`) and `outputhl` (default `inputhl`) can be used +to set the highlighting of the inputs and outputs to `graph` respectively. If set to `nothing` no special highlighting +will be used. + +All other keyword arguments are forwarded to `PrettyTables.pretty_table`. Note that this allows for overriding the +default formatting, alignment and highlighting. + +!!! warning "API Stability" + While this function is part of the public API for natural reasons, the exact shape of its output shall not be considered stable. + + `Base.show` for `CompGraph`s just forwards all arguments and keyword arguments to this method. This might change in the future. + +### Examples +```jldoctest +julia> using NaiveNASlib + +julia> g = let + v1 = "v1" >> inputvertex("in1", 1) + inputvertex("in2", 1) + v2 = invariantvertex("v2", sin, v1) + v3 = conc("v3", v1, v2; dims=1) + CompGraph(inputs(v1), v3) + end; + +julia> graphsummary(g) +┌────────────────┬───────────┬────────────────┬───────────────────┐ +│ Graph Position │ Vertex Nr │ Input Vertices │ Op │ +├────────────────┼───────────┼────────────────┼───────────────────┤ +│ Input │ 1 │ │ │ +│ Input │ 2 │ │ │ +│ Hidden │ 3 │ 1,2 │ + (element wise) │ +│ Hidden │ 4 │ 3 │ sin │ +│ Output │ 5 │ 3,4 │ cat(x..., dims=1) │ +└────────────────┴───────────┴────────────────┴───────────────────┘ + +julia> graphsummary(g, name, "input sizes" => nin, "output sizes" => nout) +┌────────────────┬───────────┬────────────────┬───────────────────┬──────┬─────────────┬──────────────┐ +│ Graph Position │ Vertex Nr │ Input Vertices │ Op │ Name │ input sizes │ output sizes │ +├────────────────┼───────────┼────────────────┼───────────────────┼──────┼─────────────┼──────────────┤ +│ Input │ 1 │ │ │ in1 │ │ 1 │ +│ Input │ 2 │ │ │ in2 │ │ 1 │ +│ Hidden │ 3 │ 1,2 │ + (element wise) │ v1 │ 1,1 │ 1 │ +│ Hidden │ 4 │ 3 │ sin │ v2 │ 1 │ 1 │ +│ Output │ 5 │ 3,4 │ cat(x..., dims=1) │ v3 │ 1,1 │ 2 │ +└────────────────┴───────────┴────────────────┴───────────────────┴──────┴─────────────┴──────────────┘ +``` +""" +graphsummary(g::CompGraph, extracolumns...; kwargs...) = graphsummary(stdout, g, extracolumns...; kwargs...) +function graphsummary(io, g::CompGraph, extracolumns...; + inputhl=PrettyTables.crayon"fg:black bg:249", + outputhl=inputhl, + kwargs...) + t = summarytable(g, extracolumns...) + + # Default formatting + arraytostr = (x, args...) -> x isa AbstractVector ? join(x, ",") : isnothing(x) ? "" : x + rowhighligts = PrettyTables.Highlighter(Returns(true), function(h, x, i, j) + !isnothing(inputhl) && i <= length(inputs(g)) && return inputhl + !isnothing(outputhl) && i > length(t[1]) - length(outputs(g)) && return outputhl + length(t[1]) > 7 && iseven(i - isnothing(inputhl) * length(inputs(g))) && return PrettyTables.crayon"fg:white bold bg:dark_gray" + PrettyTables.crayon"default" + end) + + PrettyTables.pretty_table(io, t; + show_subheader=false, + formatters=arraytostr, + highlighters = rowhighligts, + alignment = :l, + kwargs...) +end + +function summarytable(g::CompGraph, extracols...) + vs = vertices(g) + + inds = sort(collect(eachindex(vs)); by = function(i) + vs[i] in inputs(g) && return i - length(vs) + vs[i] in outputs(g) && return i + length(vs) + i + end) + + vs_roworder = vs[inds] + + NamedTuple(( + Symbol("Graph Position") => map(v -> v in inputs(g) ? :Input : v in outputs(g) ? :Output : :Hidden, vs_roworder), + Symbol("Vertex Nr") => inds, + Symbol("Input Vertices") => map(v -> something.(indexin(inputs(v), vs), -1), vs_roworder), + :Op => op.(vs_roworder), + map(c -> _createextracol(c, vs_roworder), extracols)... + )) +end + +_createextracol(f, vs) = Symbol(uppercasefirst(string(f))) => f.(vs) +_createextracol(p::Pair, vs) = Symbol(first(p)) => last(p).(vs) + +## Other stuff related to printing long arrays of numbers assuming patterns which often happen +## when mutating, typically long streaks of -1 and ascending integers. compressed_string(x) = string(x) struct RangeState diff --git a/src/vertex.jl b/src/vertex.jl index 7c392bbf..a2aaa0c6 100644 --- a/src/vertex.jl +++ b/src/vertex.jl @@ -145,4 +145,8 @@ Will return a generic string describing `v` if no name has been given to `v`. Note that names in a graph don't have to be unique. """ name(v::AbstractVertex) = string(nameof(typeof(v))) -name(v::InputVertex) = v.name \ No newline at end of file +name(v::InputVertex) = v.name + +op(::InputVertex) = nothing +op(v::CompVertex) = op(v.computation) +op(f) = f diff --git a/test/examples/quicktutorial.jl b/test/examples/quicktutorial.jl index 53dbb4a9..be77d7ce 100644 --- a/test/examples/quicktutorial.jl +++ b/test/examples/quicktutorial.jl @@ -4,21 +4,20 @@ md""" ## Construct a very simple graph Just to get started, lets create a simple graph for the summation of two numbers. """ -using NaiveNASlib, Test +using NaiveNASlib, Test # We'll make use of @test below to condense verbose output md""" NaiveNASlib uses a special immutable type of vertex to annotate inputs so that one can be certain that size mutations won't suddenly change the input shape of the model. """ @testset "First example" begin #src -in1 = inputvertex("in1", 1) -in2 = inputvertex("in2", 1) +in1 = inputvertex("in1", 1); +in2 = inputvertex("in2", 1); # In this example we could have done without them as we won't have any parameters to # change the size of, but lets show things as they are expected to be used. # # Create a new vertex which computes the sum of `in1` and `in2`: -add = "add" >> in1 + in2 -@test add isa NaiveNASlib.AbstractVertex +add = "add" >> in1 + in2; # NaiveNASlib lets you do this using [`+`](@ref) which creates a new vertex which sums it inputs. # Use `>>` to attach a name to the vertex when using infix operations. @@ -28,17 +27,26 @@ add = "add" >> in1 + in2 graph = CompGraph([in1, in2], add) # Evaluate the function represented by `graph` by just calling it. -@test graph(2,3) == 5 -@test graph(100,200) == 300 +@test( #src +graph(2,3) + == 5) #src +#- +@test( #src +graph(100,200) + == 300) #src # The [`vertices`](@ref) function returns the vertices in topological order. @test vertices(graph) == [in1, in2, add] @test name.(vertices(graph)) == ["in1", "in2", "add"] +# The [`graphsummary`](@ref) function can be used to print a summary table: +graphsummary(graph, name, "Name of inputs" => v -> name.(inputs(v))) + # [`CompGraph`](@ref)s can be indexed: -@test graph[begin] == graph[1] == in1 +@test graph[begin] == graph[1] == in1 @test graph[end] == graph[3] == add @test graph[begin:end] == vertices(graph) + # This is a bit slow though as it traverses the whole graph each time. It is better to # call [`vertices`](@ref) first and then apply the indexing if one needs to do this many times. end #src @@ -75,11 +83,14 @@ module TinyNNlib l.W = NaiveNASlib.parselect(l.W, 1=>newouts, 2=>newins[]) end + ## This makes LinearLayer print nice in the graphsummary table + Base.show(io::IO, l::LinearLayer) = print(io, "LinearLayer(", only(nin(l)), " => " ,nout(l), ')') + ## Helper function which creates a LinearLayer wrapped in an vertex in a computation graph. ## This creates a Keras-like API linearvertex(in, outsize) = absorbvertex(LinearLayer(nout(in), outsize), in) export linearvertex, LinearLayer -end +end; md""" There are a handful of other functions one can implement to e.g. provide better defaults and offer other forms of convenience, @@ -97,9 +108,9 @@ Lets do a super simple example where we make use of the tiny neural network libr @testset "Second example" begin #src using .TinyNNlib -invertex = inputvertex("input", 3) -layer1 = linearvertex(invertex, 4) -layer2 = linearvertex(layer1, 5) +invertex = inputvertex("input", 3); +layer1 = linearvertex(invertex, 4); +layer2 = linearvertex(layer1, 5); # Vertices may be called to execute their computation alone. # We generally outsource this work to [`CompGraph`](@ref), but now we are trying to illustrate how things work. @@ -107,11 +118,12 @@ batchsize = 2 batch = randn(nout(invertex), batchsize) y1 = layer1(batch) @test size(y1) == (nout(layer1), batchsize) == (4, 2) + y2 = layer2(y1) @test size(y2) == (nout(layer2), batchsize) == (5, 2) # Lets change the output size of `layer1`. First check the input sizes so we have something to compare to. -@test [nout(layer1)] == nin(layer2) == [4] +@test [nout(layer1)] == nin(layer2) == [4] @test Δnout!(layer1 => -2) # Returns true if successful @test [nout(layer1)] == nin(layer2) == [2] @@ -120,8 +132,9 @@ y2 = layer2(y1) # The graph is still operational of course but the sizes of the activations have changed. y1 = layer1(batch) @test size(y1) == (nout(layer1), batchsize) == (2, 2) + y2 = layer2(y1) -@test size(y2) == (nout(layer2), batchsize) == (5 ,2) +@test size(y2) == (nout(layer2), batchsize) == (5, 2) end #src md""" @@ -146,26 +159,28 @@ While the previous example was simple enough to be done by hand, things can quic Lets use a small but non-trivial model including all of the above. We begin by making a helper which creates a vertex which does elementwise scaling of its input: """ -scalarmult(v, s::Number) = invariantvertex(x -> x .* s, v) +scalarmult(v, s::Number) = invariantvertex(x -> x .* s, v); # When multiplying with a scalar, the output size is the same as the input size. # This vertex type is said to be size invariant (in lack of better words), hence the name [`invariantvertex`](@ref). @testset "More elaborate example" begin #src # Ok, lets create the model: ## First a few `LinearLayer`s -invertex = inputvertex("input", 6) -start = linearvertex(invertex, 6) -split = linearvertex(start, nout(invertex) ÷ 3) +invertex = inputvertex("input", 6); +start = linearvertex(invertex, 6); +split = linearvertex(start, nout(invertex) ÷ 3); ## Concatenation means the output size is the sum of the input sizes -joined = conc(scalarmult(split,2), scalarmult(split,3), scalarmult(split,5), dims=1) +joined = conc(scalarmult(split,2), scalarmult(split,3), scalarmult(split,5), dims=1); ## Elementwise addition is of course also size invariant -out = start + joined +out = start + joined; -## CompGraph to help us run the whole thing +## CompGraph to help us run the whole thing. Don't forget to use [`graphsummary`](@ref) to help understand the structure graph = CompGraph(invertex, out) -@test graph((ones(6))) == [78, 78, 114, 114, 186, 186] +graphsummary(graph, nin, nout) +# The anonymous function in `scalarmult` does not print nicely. Consider using a callable struct and implement `Base.show` for it! +@test graph((ones(6))) == [78, 78, 114, 114, 186, 186] md""" Now we have a somewhat complex set of size relations at our hand since the sizes are constrained so that @@ -183,7 +198,7 @@ in the graph to have something to compare to. @test [nout(start), nout(joined)] == nin(out) == [6, 6] # In many cases it is useful to hold on to the old graph before mutating -parentgraph = deepcopy(graph) +parentgraph = deepcopy(graph); # It is not possible to change the size of `out` by exactly 2 due to `1.` and `2.` above. # By default, NaiveNASlib warns when this happens and then tries to make the closest possible change. @@ -284,7 +299,7 @@ function vertexandlayer(in, outsize) nparam = nout(in) * outsize l = LinearLayer(collect(reshape(1:nparam, :, nout(in)))) return absorbvertex(l, in), l -end +end; # Make a simple model: invertices = inputvertex.(["in1", "in2"], [3,4]) @@ -294,6 +309,7 @@ merged = conc(v1, v2, dims=1) v3, l3 = vertexandlayer(invertices[1], nout(merged)) add = v3 + merged v4, l4 = vertexandlayer(merged, 2) +CompGraph(invertices, v4) # These weights might look a bit odd, but here we only care about making # it easy to spot what has changed after size change below. @@ -406,7 +422,7 @@ end #src # ### Add an edge # Using [`create_edge!`](@ref): -@testset "Add edge example" begin +@testset "Add edge example" begin #src invertices = inputvertex.(["input1", "input2"], [3, 2]) layer1 = linearvertex(invertices[1], 4) layer2 = linearvertex(invertices[2], 4) diff --git a/test/prettyprint.jl b/test/prettyprint.jl index d6ddfcfe..bcd2e0d8 100644 --- a/test/prettyprint.jl +++ b/test/prettyprint.jl @@ -1,3 +1,86 @@ +@testset "CompGraph" begin + + + av(name, in, outsize) = absorbvertex(name, MatMul(nout(in), outsize), in) + + function testgraph() + iv1 = inputvertex("iv1", 1) + iv2 = inputvertex("iv2", 2) + + v1 = av("v1", iv1, 3) + v2 = av("v2", v1, 4) + + v3 = av("v3", iv2, 5) + v4 = av("v4", v1, 5) + + v5 = "v5" >> v3 + v4 + + v6 = conc("v6", v2, v3; dims=1) + + CompGraph([iv1, iv2], [v6, v5]) + end + + @testset "summarytable" begin + import NaiveNASlib: summarytable + + g = testgraph() + t = summarytable(g, "vname"=>name, nin, nout) + + vs = vertices(g) + @testset "Check vertex $i" for i in eachindex(t[1]) + v = vs[t[2][i]] + @test name(v) == t.vname[i] + @test name.(inputs(v)) == name.(vs[t[3][i]]) + @test nin(v) == t.Nin[i] + @test nout(v) == t.Nout[i] + end + end + + @testset "pretty print full" begin + g = testgraph() + str = sprint((args...) -> show(args..., "vname"=>name, nin, nout; highlighters=tuple()), g) + + expnames = name.(vertices(g)) + innames = name.(inputs(g)) + outnames = name.(outputs(g)) + hnames = setdiff(expnames, innames, outnames) + + foundnames = String[] + + for row in split(str, '\n') + if contains(row, "Input ") + m = match(Regex(string('(', join(innames, '|'), ')')), row) + @test !isnothing(m) + append!(foundnames, m.captures) + end + + if contains(row, "Hidden") + m = match(Regex(string('(', join(hnames, '|'), ')')), row) + @test !isnothing(m) + append!(foundnames, m.captures) + end + + if contains(row, "Output") + m = match(Regex(string('(', join(outnames, '|'), ')')), row) + @test !isnothing(m) + append!(foundnames, m.captures) + end + end + + @test sort(expnames) == sort(foundnames) + end + + @testset "pretty print array" begin + g = testgraph() + + str = sprint(show, CompGraph[g]) + + @test str == "CompGraph[CompGraph($(nvertices(g)) vertices)]" + + end +end + + @testset "Compressed array string" begin @test NaiveNASlib.compressed_string([1,2,3,4]) == "[1, 2, 3, 4]" @test NaiveNASlib.compressed_string(1:23) == "[1,…, 23]"