Observing

A docstring can say a name is unfinished. It cannot tell you that this run went through it.

That is the question behind the mark: not "is energy experimental", which the author already knows, but "did the number in this figure come out of code nobody validated" — asked after the run, about the run, by somebody who may not have written either.

Without asking

using ExperimentalAPI

@experimental "convergence not established below β ≈ 0.1" energy(β) = β * 1.0000001

energy(0.5)
$ julia sweep.jl
┌ ExperimentalAPI: this run entered 1 experimental definition
│   Main.energy — convergence not established below β ≈ 0.1
└ set ENV["EXPERIMENTALAPI_SUMMARY"] = "0" before `using` to silence this

Three properties, each of them a decision:

  • On by default. The user who never asks is the one who needs telling. detecting reports whether the hook is armed; the environment variable has to be set before using ExperimentalAPI, because that is when atexit is registered.
  • Silent unless something was entered. Loading a package that has marks prints nothing. A package that cannot be quiet is one people vendor around.
  • Carries the reason. The name says which line to open; the reason says whether the result is affected.

As data

entered returns the same thing the summary prints, as a Vector{Entry}:

julia> MyModel.energy(0.5)-1.6265233750364456
julia> ExperimentalAPI.entered(MyModel)1-element Vector{ExperimentalAPI.Entry}: Entry(Main.MyModel.energy, "convergence not established below β ≈ 0.1")

Asked about a module rather than about the whole process, because the process-wide answer includes every marked package loaded — on this page, the fixtures the documentation built. The fields it is read for do not depend on where it ran, and those are checked as a doctest:

julia> using ExperimentalAPI

julia> @experimental "convergence not established below β ≈ 0.1" energy(β) = β * 1.0000001

julia> energy(0.5);

julia> only(ExperimentalAPI.entered()).name
:energy

julia> only(ExperimentalAPI.entered()).reason
"convergence not established below β ≈ 0.1"

julia> only(ExperimentalAPI.entered()).count === nothing
true

A marked definition the run never entered is absent, not reported with a count of zero — the difference between "observed" and "enumerated".

marked_modules is the search this uses: the loaded modules that carry marks, found by walking rather than by a registry inside this package, because a table here would be written while the marked package is precompiled and so would be missing from its cache image. summary_text is what the exit hook prints, available as a string for a report of your own.

What it costs

One short-circuit read in the body, and a write on the first call only.

emitted into the body1 thread8 threadscounts correctly?
nothing1.00×1.00×—
the flag @experimental emits1.03×0.985×yes
a counter, plain shared Ref1.03×3.76×no — 40% lost to races
a counter, global atomic1.17×4.87×yes
a counter, per-thread atomic1.12×2.79×yes
@warn, guarded so it fires once5.65×—yes
@warn maxlog=159.57×—yes

10M calls of sqrt(abs(sin(x)cos(x) + exp(-|x|/1e6))), minimum of 7–9 trials, Julia 1.12.2.

Two of those rows decided the design. A flag written once and only read afterwards stops dirtying the cache line, which is why it is free at eight threads while every counting scheme is not. And the guarded @warn costs 5.65× even though it fires once: what stops the definition inlining is the call being in the body at all, not the warning being printed. That is why the notice is a summary at exit rather than a warning at the call.

What the default layer does not answer

  • How often. Presence only — Entry.count is always nothing.
  • Which method. Flags are name-keyed, so two methods of a marked name share one.
  • Which call site, or by what path.
  • Anything about a declaration-only mark. A name list, a struct, a const, a module: recorded and audited, never observed. See @experimental for the table.

The first three are record's, and it is a call rather than a default because of the table above.

Recording: counts, paths and time

r = ExperimentalAPI.record(; paths = false) do
    MyModel.simulate(βs; steps = 500)
end
r
Record — 1 marked definition entered in 0.000713s
  Main.MyModel.energy  ×20000 — convergence not established below β ≈ 0.1
  recorder overhead ≈ 10.0%

paths = false here because a captured path is the whole call stack, and on this page that starts at _start and runs through makedocs. The timings are missing: a documentation build does not load Profile, and record reports what it measured rather than a zero.

record returns a Vector-like of Hit, so isempty(r) and r[1].count read the way they look — with the properties an empty vector could not carry:

propertywhy it is not just a vector
enabledan empty record means "nothing was entered"; without this it is indistinguishable from "nothing was recorded", and those are opposite statements
slotsthread slots the counters were sized for, at least Threads.maxthreadid() — the interactive pool means a task's thread id can exceed nthreads()
overheadthe recorder's estimated share of the elapsed time, from a calibrated per-hit cost
versionsenergy being experimental in v0.3 says nothing about v0.9

Asking about one call

record(() -> f(x)) is the function form, and it is what everything here is built on. @entered is the same question asked about an expression, and it knows two things a closure cannot — the source text of the call and the line it is written on:

report = IOBuffer()
value = ExperimentalAPI.@entered report MyModel.sweep(βs)
print(String(take!(report)))
┌ @entered MyModel.sweep(βs)   at observing.md:163
│   Main.MyModel.energy  ×40 — convergence not established below β ≈ 0.1
└ 1 of 2 observable marked definitions was not entered

The footer counts every observable marked definition loaded in the process, not only the ones in your package.

It returns the value of the expression, so it drops into existing code the way @time does — with one divergence @time does not have, since record takes a function and the expression therefore runs inside a closure: a return inside it returns from the closure, and @entered y = f(x) binds y inside the closure. Write y = @entered f(x). The last line is what makes a clean answer mean anything:

ExperimentalAPI.@entered report round(1.0; digits = 2)
print(String(take!(report)))
┌ @entered round(1.0; digits = 2)   at observing.md:177
└ entered nothing marked — 2 observable marked definitions were loaded

"Entered nothing" and "nothing is marked anywhere" are different states, and a package that has not adopted this yet is in the second one. A report that could not tell them apart would read as reassurance on a package where nothing had ever been declared.

What it returns is the record's value — record carries the block's result out, so measuring a call does not cost you that result. It is record(() -> expr; paths = false, timing = false) plus the report: the cheap question, which and how often, needing neither a backtrace nor a sampler. For call paths, time (never both — see record), or the Record as data, call record.

Name an io first to send the report somewhere else — @entered log sweep(model) into a file, @entered devnull f(x) to keep only the value. The default is looked up when the block runs, not when the macro expands, so redirect_stdout still catches it.

The route is deliberately not printed: a captured path is a list of frame names, and Base's higher-order functions are in it. Measured for driver(x, n) = sum(inner(x) for _ in 1:n), the captured path runs driver → sum → #sum#278 → sum → #sum#277 → mapreduce → #mapreduce#274 → mapfoldl → #mapfoldl#270 → mapfoldl_impl → foldl_impl → _foldl_impl → MappingRF → #driver##0 → inner → energy — three names the reader wrote and thirteen they did not. Separating the two needs paths to carry which module each frame came from, which is a change to what Hit.paths means.

How it counts without a counter in the body

The emitted statement never changes. Opening a block clears every probe's flag, so the short-circuit fails and the write side runs on every call — and the write side is a function call, not an inlined store, so it can afford to count. Counts are therefore exact and survive inlining, which is what ruled out the sampling route: a definition small enough to be worth marking is small enough to be inlined, and a sampler has no frame left to attribute to.

Counts are exact under threads too: per-thread counters, sized by maxthreadid() and padded so two threads never share a cache line, summed at the end.

paths is a bounded sample rather than a complete list — a backtrace costs microseconds, so the recorder stops looking once it has seen enough. The paths a marked definition is reached by are few and repeat.

Time

inclusive and exclusive come from Julia's sampling profiler, through a package extension: without using Profile they are missing, which is not zero. A run that nobody timed has no fraction to report, and 0.0 would say the opposite.

experimental_fraction is the share of the run spent inside marked code, derived from the inclusive times — so it is time and not calls. One entry into a marked kernel that runs for a minute matters more than a million into a marked accessor.

attribute does the same for a profile buffer that already exists, which is the twelve-hour-run case: a job that was already profiled must not have to be run again. What comes back is Attribution — samples, never calls, because a sampling profiler cannot count entries and a field called count holding a sample total would read as a measurement it did not make.

As a gate, and as evidence

assert_clean turns a record into a refusal:

ExperimentalAPI.assert_clean() do
    publish(compute(model))
end

write_record and stamp write it down instead. Both produce plain TOML, because a year later the package that made the figure may not resolve — and a provenance record nobody can open is not one.