Introspection for checking an implementation

I am trying to implement an automated checking procedure for programming assignments. For example, I may ask a user to write function my_sum() for computing the sum of elements of a vector, then verify that he did not use the sum() function. Then I ask to write function to compute the mean of a vector’s elements, and would like to automatically verify that he used my_sum(), rather than copy-pasting its internal implementation.

All that is implemented in a Pluto notebook, so I can’t analyze the Expr tree. So, as suggested in this discussion, I look at the IR:

ci=Base.code_typed(my_mean,(AbstractVector,))[1][1]

Is it enough to verify that one of Expr elements of ci.code

a=[c.args[1] for c in ci.code if isa(c,Expr) && c.head===:call]

matches my_sum?

any(x->x.name===:my_sum, a)
1 Like

Small note: I suggest using code_lowered instead of code_typed. That way you avoid some transformations of the source, inlining in particular.

A sneaky student who’s aware of your testing methodology might write something like

julia> function totally_not_base_sum(x)
           return sum(x)
       end;

julia> function my_sum(x)
           return totally_not_base_sum(x)
       end;

which the code_... approach would fail to detect:

julia> Base.code_lowered(my_sum, (AbstractVector,))  # no sum here
1-element Vector{Core.CodeInfo}:
 CodeInfo(
1 ─ %1 = Main.totally_not_base_sum(x)
└──      return %1
)

There’s probably a lot of downsides, but you could override sum to make sure it does not work:

julia> Base.sum(x::AbstractVector) = error("I KNOW WHAT YOU DID")

julia> my_sum(rand(3))
ERROR: I KNOW WHAT YOU DID
Stacktrace:
 [1] error(s::String)
   @ Base .\error.jl:35
 [2] sum(x::Vector{Float64})
   @ Main .\REPL[4]:1
 [3] totally_not_base_sum(x::Vector{Float64})
   @ Main .\REPL[1]:2
 [4] my_sum(x::Vector{Float64})
   @ Main .\REPL[2]:2
 [5] top-level scope
   @ REPL[5]:1
3 Likes

Yeah, well, if a student is that good, he’ll ace the assignment without sneaky tricks. It’s really a preliminary check before I grade the assignment.

But since you’ve mentioned it, can this test be applied recursively to all calls?

There’s probably a lot of downsides, but you could use override sum to make sure it does not work:

I’ve considered this approach. Can this override be limited in scope within the checking block?

Fair point! :slight_smile:

Presumably, but I don’t know if there’s a relatively easy way.

I’m not fully sure this is what you mean, but you could restore Base.sum by invoking from a previous world:

julia> world_age = Base.get_world_counter()
0x0000000000006882

julia> x = rand(3);

julia> sum(x)
0.8830781962330426

julia> Base.sum(x::AbstractVector) = error()

julia> sum(x)
ERROR:
Stacktrace:
 [1] error()
   @ Base .\error.jl:44
 [2] sum(x::Vector{Float64})
   @ Main .\REPL[4]:1
 [3] top-level scope
   @ REPL[5]:1

julia> Base.sum(x::AbstractVector) = Base.invoke_in_world(world_age, sum, x)

julia> sum(x)
0.8830781962330426
1 Like

Maybe you could use TraceFuns.jl to check if a particular function got used:

julia> using TraceFuns

julia> function totally_not_base_sum(x)
           return sum(x)
       end;

julia> function my_sum(x)
           return totally_not_base_sum(x)
       end;

julia> @trace my_sum(1:10) Base.sum
       2: sum(1:10) -- Method sum(r::AbstractRange{<:Real}) @ Base range.jl:1405 of sum
       2: sum(1:10) -> 55
55

julia> function my_real_sum(x)
           reduce(+, x)
       end
my_real_sum (generic function with 1 method)

julia> @trace my_real_sum(1:10) Base.sum
55
# Success: No printout, i.e., sum was not called!
4 Likes

That’s what I am looking for.
It relies on fairly advanced infrastructure of Cassete.jl. I wonder if MWE may be created using only build-in Julia introspection tools?

How far does this sum(input) check go though? Would mapreduce(identity, +, input) also be an unacceptable cop-out?

Base names are only implicitly imported, so why not shadow it in the first place with sum = nothing if we want sum to not be usable yet Base.sum’s implementation to exist? Could tell the students to paste lines at the start, and that’s easy to check.

1 Like

I tried to do it in the following way:


code_info(ex::Symbol)=Base.code_lowered(eval(ex))
code_info(exs::AbstractVector{Symbol})=reduce(vcat, code_info.(exs))
global_references(ci::Core.CodeInfo) = Symbol[c.name for c in ci.code if isa(c, GlobalRef)]
global_references(ci::AbstractVector{Core.CodeInfo})=unique(reduce(vcat,global_references.(ci)))

followed by the loop

for k in 1:recursion_depth_limit
    ci=code_info(s)
    s=global_references(ci)
    if any(x->x===bad_symbol, s)
        return :bad
    end
end
return :good

and quickly found out that methods of / call sum. If there’s a way to exclude calls to functions imported into the module, that would fix this problem.

So far as I understood it, Cassette.jl uses generated functions to alter the function calls, so it will only see the methods that are actually called.

1 Like