Table logging in Julia

Creation date: 2020-02-19

This is kinda part of the constraint solver series => Building a constraint solver in Julia but is useful for more than this and might be its own package (yeah I say that often :D)

There were some changes to the constraint solver which still need a post and I'm not satisfied yet to write a post about it but I should...

When I started with Julia and Juniper.jl I needed to write a table logger to display the current status of the solver. By status I mean information like best solution found so far (incumbent) and best bound as well as how long did it run so far.

Basically the table from this:

Now I of course also need this for the constraint solver project but I didn't like the code that much and spoiler alert: I'm not completely satisfied with the current implementation now but I think I should put it out here and maybe some others have ideas on how to improve it.

What I want

I want to easily define the table by giving the header information which includes the type, the width and some other information.

In the solver I want to push something to the table and given some criteria the table should decide whether this is a new row or whether there it's not worth it i.e in the constraint solver there can be a lot of backtracking steps but they can be quite fast for example in the case of solving sudokus. I don't want a line for each step in the backtrack! function. Normally the user wants to get updates to see that it's running or if a new solution was found. Therefore I use the rule that a new line should be added every five seconds or if there is a new solution.

Additionally the type of the column should define some formatting rules which the developer should be able to change.

Some problems

Not too sure how to make it as extensible as possible so this is my try of doing it and I welcome your comments/issues/PRs.

Current implementation

TableLogger.jl
mutable struct TableCol
id                  :: Symbol
name                :: String
type                :: DataType
width               :: Int
alignment           :: Symbol # :left, :center, :right
b_format            :: Bool
end

mutable struct TableEntry{T}
col_id              :: Symbol
value               :: T
end

mutable struct TableSetup
cols                :: Vector{TableCol}
col_idx             :: Dict{Symbol,Int}
new_row_criteria    :: Bool
diff_criteria       :: Dict{Symbol, Any}
last_row            :: Vector{TableEntry}
end

The table is defined by the columns cols might have some criteria to check if a new row should be added or now which depends on last_row and diff_criteria will be passed to the function of deciding whether a new row will be added. I need the col_idx to know which column is at which position. Maybe that can be done in a different fashion.

TableCol has a type which defines whether some formatting should be used. Each row is a vector of TableEntry which defines the column id id of TableCol and the value. The value type doesn't have to be the same as the column type but is normally advised. I wanted to be able to directly set the incumbent value to "-" if no solution has been found yet. That could be done in formatting as well or having a union of Nothing and T for that.

Furthermore I created some constructor functions but those are boring. They check whether format_table_value exists for the given type i.e

function format_table_value(val::Int, len::Int)
s_val = string(val)
if length(s_val) > len
return val > 0 ? ">>" : "<<"
end
return s_val
end

If we have an integer value it will be formatted like this. I also have a function for Float64: function format_table_value(val::Float64, len::Int).

Then we have the function to create the header:

function get_header(table::TableSetup)
ln = ""
sum_width = 0
for col in table.cols
width     = col.width
sum_width += width
col.width = length(col.name)+2
end
ln *= col.name
end
equals = repeat("=", sum(sum_width)+2*length(table.cols))
header = "$ln\n$equals"
end

Here fld and cld which are shorter and faster forms of convert(Int, floor(padding/2)) and c for ceil instead of floor. Additionally I add another space on both sides so that the actual width is actually width+2. Basically some kind of margin.

#Open    #Closed       Incumbent           Best Bound      Time [s]
======================================================================

if

table = TableSetup(
[
CS.TableCol(:open_nodes, "#Open", Int, 10, :center),
CS.TableCol(:closed_nodes, "#Closed", Int, 10, :center),
CS.TableCol(:incumbent, "Incumbent", Float64, 20, :right),
CS.TableCol(:best_bound, "Best Bound", Float64, 20, :right),
CS.TableCol(:duration, "Time [s]", Float64, 10, :center)
],
Dict(:min_diff_duration=>5.0)
)

Currently I don't use the alignment for the header.

Then the same thing for get_row:

function get_row(table::TableSetup, row::Vector{TableEntry})
ln = ""
for c=1:length(table.cols)
width   = table.cols[c].width
if isassigned(row, c)
val     = row[c].value
if table.cols[c].b_format && isa(val, table.cols[c].type)
s_val   = format_table_value(val, width)
else
s_val   = string(val)
end
else
s_val = "-"
end

if table.cols[c].alignment == :center
ln *= s_val
elseif table.cols[c].alignment == :left
ln *= " "
ln *= s_val
elseif table.cols[c].alignment == :right
ln *= s_val
ln *= " "
else
@warn "Only the alignments :left, :right and :center are defined."
end
end
return ln
end

As mentioned before b_format is set if we have the format_table_value function with the right input. That can be done with hasmethod

help?> hasmethod
search: hasmethod

hasmethod(f, t::Type{<:Tuple}[, kwnames]; world=typemax(UInt)) -> Bool

Determine whether the given generic function has a method matching the given Tuple of argument types with the upper bound of world age given by world.

If a tuple of keyword argument names kwnames is provided, this also checks whether the method of f matching t has the given keyword argument names. If the matching method
accepts a variable number of keyword arguments, e.g. with kwargs..., any names given in kwnames are considered valid. Otherwise the provided names must be a subset of the
method's keyword arguments.

We should only format it this way if the value in the TableEntry actually has the column type. Otherwise it will be converted to a String.

The last thing we need to do is define a way of how to add something to the table. I thought it might be handy to for the developer/user to write functions like:

push_to_table!(table; open_nodes=12, incumbent=100, closed_nodes=17)

Therefore we work with keyword arguments in push_to_table.

function push_to_table!(table::TableSetup; force=false, kwargs...)
row = Vector{TableEntry}(undef, length(table.cols))
for p in kwargs
col_idx = get(table.col_idx, p.first, 0)
if col_idx != 0
row[col_idx] = TableEntry(p.first, p.second)
end
end
if force || !table.new_row_criteria || is_new_row(row, table.last_row, table.diff_criteria)
println(get_row(table, row))
table.last_row = row
end
return
end

I also added the possibility to force a new row which seems reasonable and I use it if a new solution was found. If a is_new_row function exists the new_row_criteria field will be true and then it is checked given the new row, last_row and the diff_criteria which is a dictionary.

Ways to improve

Currently I don't know what might be the best way of using your own formatting rules for Ints. I think mine can be a reasonable default but how to use a different one without removing the default function. One way is to define a new Type like

mutable struct MyInt
value :: Int
end

but that is a bit inconvenient. Otherwise the new function needs to be more specific then the default function i.e I could define function format_table_value(val::Int, len) and you define:

function format_table_value(val::Int, len::Int)

(len::Int) is more specific than len but maybe there is a better way in Julia.

Sometimes for debugging (the printing out style :D) it would be nice to have an extra column called watch or something where you can print out the search space of certain variables without changing code inside the solver. Then the ConstraintSolverModel needs to be added to push_to_table! but I'm not too sure how to do that in the best fashion yet.

Would love to hear from you about ways to improve it and whether this might be helpful for your own project.

If you haven't seen my previous post about the Enigma or missed the video about it: Check it out!

I am thinking about making some videos for some topics now. Check out my YouTube channel: Opensources.

New Constraint Solver post: Linear bounds

Thanks for reading and special thanks to my five patrons!

List of patrons paying more than 4<span>$</span> per month: • Site Wang • Gurvesh Sanghera • Szymon Bęczkowski Currently I get 12<span>$</span> per Month using Patreon and PayPal when I reach my next goal of 50<span>\$</span> per month I'll create a faster blog which will also have a better layout:

• Code which I refer to on the side

• Code diffs for changes

• Easier navigation with Series like Simplex and Constraint Programming

• And more