Skip to content

feat: implement the case matrix — records, reachability, methods, and the mark's exit - #13

Merged
sotashimozono merged 5 commits into
mainfrom
feat/complete-the-mechanism
Sep 5, 2026
Merged

feat: implement the case matrix — records, reachability, methods, and the mark's exit#13
sotashimozono merged 5 commits into
mainfrom
feat/complete-the-mechanism

Conversation

@sotashimozono

Copy link
Copy Markdown
Member

test/spec/ was written before the implementation: 174 behaviours of which 111 were
@test_broken
. This fills them in. 176 behaviours, all live assertions, 0 broken.

What each layer is, and what it costs

question added cost
what this run entered unchanged one short-circuit read; 1.03× / 0.985×
how often, by which paths, how much of the run record opt-in, and each record reports its own overhead
what a caller depends on without naming it reach static, three-valued
what is unfinished, and which public names are undescribed method-level marks, the method half of audit, the coverage join
may this mark go until=, ready_to_promote, age, stale_since

record emits nothing new

The statement @experimental puts in a body did not change. Opening a block clears every
probe's flag
, so the short-circuit fails and the write side — a function call, not an inlined
store — does the counting. Counts are therefore exact, survive inlining (which is what ruled out
the sampling route: a definition worth marking is small enough to be inlined, and a sampler has no
frame left to attribute to), and are correct under threads via per-thread padded counters sized by
maxthreadid().

One change to the emission is load bearing and was found the hard way: the probe statement now
carries the declaration's own LineNumberNode. Without it the cold write branch inherits whatever
statement the optimiser leaves next to it, and record's call paths came back reporting iterate
and + where energy and inner belonged.

Time comes from Julia's sampler through a new Profile extension, so inclusive/exclusive are
missing — never 0.0 — when nobody measured.

reach has three answers because Julia's call graph is not closed

:depends, :clean, and :unknown for a call site that cannot be pinned to a method. Walks
inferred, un-optimised IR: optimize = true sees mul_float and finds nothing. A site with
several matching candidates is walked through all of them, so "cannot tell which method" only
becomes :unknown when one of the candidates is actually marked — otherwise convert(::Type, x)
would make every caller that formats a string unknown.

There is deliberately no verdict field: a stored one makes :clean with a non-empty
unresolved representable, and that is the single state this must never report.

Defects the spec caught

All of them a mark silently recording the wrong thing rather than refusing:

  1. (c::C)(x) = … marked :c, the argument name — and the audit then reported :c dangling
    and :C unaccounted, telling the author to declare the very thing that line declares.
  2. since = "0.4.0" and a non-string reason were refused by the field's own conversion, with a
    MethodError naming neither the keyword nor @experimental.
  3. A name-keyed mark made every sibling method experimental. Marks now record the signature they
    attached to; stable keeps a name in the covenant until every method behind it is marked.
  4. The spec's own invoke case named an entry signature no method of its fixture matched, and its
    @eval-in-a-loop fixture interpolated at the wrong level. Both invisible while Broken.

One requirement withdrawn, with the measurement

A mark inside a function body cannot be refused with a message naming @experimental. const in
local scope fails during lowering, before any emitted code runs, and Julia's message does not
name the variable either — measured byte-identical for __EXPERIMENTAL_API_MARKS__ and for a
binding whose name is the whole explanatory sentence. global fails silently; Core.eval removes
the error altogether. test/spec/README.md records all three routes. What is kept and asserted is
the part that was in this package's hands: the blame lands on the author's line, never inside this
package.

Verification

Every test file individually on 1.12.2 and 1.11.9, plus one full runtests.jl: 977
assertions, green, 2m49s
with coverage and four threads. Docs build clean; the API reference is
split by source file so a new file with no page is a build failure.

Overlaps with the open PRs

🤖 Generated with Claude Code

… the mark's exit

`test/spec/` was written before the implementation: 174 behaviours of which 111 were
`@test_broken`. This fills them in. 176 behaviours now, all live assertions, 0 broken.

The five questions the package answers, and what was added for each:

  * **what this run entered** — unchanged, and now the floor rather than the whole thing.
  * **how often, by which paths, how much of the run** — `record`. It emits nothing new: opening
    a block clears every probe's flag, so the short-circuit fails and the *write* side, which is
    a call rather than an inlined store, does the counting. Counts are exact, survive inlining,
    and are correct under threads (per-thread padded counters sized by `maxthreadid()`). Time
    comes from Julia's sampler through a new `Profile` extension, so `inclusive`/`exclusive` are
    `missing` — never `0.0` — when nobody measured. `assert_clean`, `write_record`, `stamp`.
  * **what a caller depends on without naming it** — `reach`, over inferred un-optimised IR.
    Three-valued: `:depends`, `:clean`, and `:unknown` for a call site that cannot be pinned to a
    method. A site with several candidates is walked through all of them, so "cannot tell which"
    only becomes `:unknown` when one of them is actually marked. Module and script entry points,
    `ignore=`, `dependents`.
  * **what is unfinished** — marks now record the signature they attached to, so `reach` reports
    the marked dispatch path and not its siblings, while `audit` still reads the name. Qualified
    definitions (`Base.show(io, ::T) = …`) are accepted as method-level marks and stored in the
    module that wrote them; `mark_method!` is the imperative route. `audit` gained the method
    half — `contributed_methods`, `unaccounted_methods` — which is the only half a package whose
    surface is `fetch(model, quantity)` has. `verification`/`coverage` join marks against
    `--code-coverage`, flushed in-process.
  * **may this mark go** — `until=` states the exit condition next to the reason;
    `ready_to_promote` calls it, `marks_without_exit` reports the marks that never said, `age`
    and `stale_since` read `since`.

Defects the spec caught, all of them a mark silently recording the wrong thing:

  * `(c::C)(x) = …` marked `:c`, the argument name — and the audit then reported `:c` dangling
    AND `:C` unaccounted, telling the author to declare what that line declares. Both fixed.
  * `since = "0.4.0"` and a non-string reason were refused by the field's own conversion, with a
    `MethodError` naming neither the keyword nor `@experimental`.
  * a name-keyed mark made every sibling method experimental; `stable` now keeps a name in the
    covenant until every method behind it is marked.
  * the spec's own `invoke` case named an entry signature no method matched, and its
    `@eval`-in-a-loop fixture interpolated at the wrong level. Both invisible while Broken.

One requirement was **withdrawn**: a mark inside a function body cannot be refused with a message
naming `@experimental`. `const` in local scope fails during lowering, before any emitted code
runs, and Julia's message does not name the variable either — measured byte-identical for a
binding whose name is the whole explanatory sentence. `test/spec/README.md` records the three
routes and why each fails; what is kept and asserted is that the blame lands on the author's line.

Also: `@generated`, `Base.@kwdef`, `@inline` and the other annotating macros compose with the
mark; a Documenter extension renders `@experimental` blocks; the API reference is split by source
file so a new file with no page is a build failure.

Verified: every test file individually on 1.12.2 and 1.11.9, and one full `runtests.jl` — 977
assertions, green, 2m49s with coverage and four threads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the enhancement New feature or request label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📚 Docs preview: https://codes.sota-shimozono.com/ExperimentalAPI.jl/previews/PR13/

(updates on each push to this PR)

`marks_markdown` was edited after the formatting pass — the heading-free rewrite that the
Documenter HTML writer's `!(node.element isa MarkdownAST.Heading)` assertion forced — so
`format / format-check` was red on one function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

sotashimozono and others added 3 commits September 4, 2026 15:00
`julia nightly — ubuntu-latest` is `continue-on-error`, which makes it allowed to fail and not
allowed to be invisible. It failed 6 of 977. Both causes are real, and one of them was a false
"fully covered" that would have reached a release.

**`Type{X}` is no longer a `DataType`** (measured on 1.14.0-DEV.3115), and `Core.Typeof(Float64)`
now returns the new `Core.TypeEgal{Float64}` rather than `Type{Float64}`. Two `ft isa DataType`
guards — one in `_is_callable_type`, one in `_ftype_identity` — therefore stopped recognising
every constructor call in the graph, and four otherwise-clean fixtures came back `:unknown`. Both
now ask about `t` rather than about how `t` is represented: `t <: Type` with one non-`TypeVar`
parameter, read through `_type_parameter`, and normalised to `Type{X}` before a signature is
built.

**`--code-coverage` now emits a counter for the definition line of a method nothing ever called.**
Up to 1.12 that line had no counter at all, which is what `unverified` was reading: no counters
anywhere in the span meant nothing had ever generated code for it. On 1.14-DEV a one-line
definition comes back at `1.0` — fully covered on the strength of having been *defined*. The two
versions disagree about the same file and only one of them can be read as "the suite ran this".

So `unverified` no longer rests on that. The exact answer was already in the package: the probe.
A marked definition whose flag never fired is `0.0` whatever the line counters say, `unverified`
needs no `--code-coverage` at all now, and coverage supplies only the partial fraction — which is
the question it can still answer. `test/spec/test_spec_verify.jl` lost a version-conditional
branch as a result.

Verified: full suite on 1.14.0-DEV.3115 (978 green), and the affected files on 1.12.2 and 1.11.9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s on nightly

`recording does not disturb Profile` failed on macOS and errored on nightly. Both are defects in
what this repository wrote, not in the platforms.

**The buffer was not reliably filled.** The test profiled `Sim.driver(M, 200_000)` — about one
millisecond, which is *one sampling interval* at Profile's default rate — and then asserted the
buffer was non-empty. On ubuntu it caught a sample; on macOS it caught zero, and `before > 0`
failed with `0 > 0`. An assertion whose subject is a sample count has to be given a run that is
long compared with the interval, so it now profiles `Hot.grind(2_000_000)` at `delay = 1e-5`.
That fixture already existed for the attribution test and moved up the file.

**`fetch(; include_meta = false)` strips metadata behind an `@assert`.** On 1.14.0-DEV.3115 that
assertion fires — `metadata stripping failed` — on a buffer this test did not fill. Two changes:
the test reads `Profile.len_data()`, which answers "how much is in the buffer" without fetching
or stripping anything; and the `Profile` extension does the strip itself through `has_meta` /
`strip_meta` with a fallback to the raw data, because an exception there would have turned
`record`'s timing silently off rather than loudly wrong.

Verified: `test/spec/test_spec_profile.jl` on 1.12.2, 1.11.9 and 1.14.0-DEV.3115.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`main` moved under this branch: #11 cut the README from 1372 words to 419 for the registry's LLM
policy, and #12 replaced the four required matrix contexts with one aggregate.

Three files conflicted, and the resolution is not "keep both halves" in any of them:

  * **README.md** — `main`'s trimmed version wins wholesale. Re-adding the long sections written
    here would undo the point of #11. What is added back is two things that are now *false*
    without them: `record` and `reach` did not exist when that README was written, and the closing
    line called `test/spec/` "the specification for the propagation and profiling work that is not
    built yet". 559 words, against the 1372 that #11 cut.
  * **src/audit.jl** and **docs/src/checking.md** — the extension blind spot has a workaround #11
    documented (declare the function in the parent, let the extension add only the method) and a
    report this branch added (`Audit.extensions`). Both are true, so both are said. The
    "methods on other packages' functions" bullet keeps this branch's version, because it stopped
    being a blind spot.

`docs/make.jl` and `.github/workflows/CI.yml` merged cleanly and correctly — #11's canonical URL
with this branch's page list, and #12's `all-tests` job with this branch's corrected runtime
comment.

Verified after the merge: `test_readme.jl`, `test_dogfood.jl`, `test_audit.jl`, the formatter, and
a full docs build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sotashimozono
sotashimozono merged commit 74893bd into main Sep 5, 2026
14 checks passed
@sotashimozono
sotashimozono deleted the feat/complete-the-mechanism branch September 5, 2026 08:18
sotashimozono added a commit that referenced this pull request Sep 8, 2026
…et (#28)

Asked whether `@experimental` itself has settled, the churn says yes — `src/mark.jl` has not been
touched since #13, across the fourteen commits since. So the question was put to a sweep of 25
definition forms instead, and the sweep found one hole, in the class this package exists to close.

    @experimental "kernel unverified" @inline f(x) = x     # mark outside
    @inline @experimental "kernel unverified" g(x) = x     # mark inside

    entered(M)  →  [:g]

Both are marked. Both **register a probe**, so both count as observable marked definitions. Only
one can ever be entered. With the mark outside, `entered` said "not entered" no matter what ran,
`@entered` and `record` reported a clean run through unvalidated code, and `unverified` reported
the definition forever.

`_subject` was right: `@inline` and its five neighbours are in `_ANNOTATING_MACROS`, the wrapper is
rebuilt around the definition, and `instrumentable` is passed through — with a comment saying "an
`@inline` marked kernel is exactly the kind that has to be observable". `_instrument`, in the other
file, began `(def.head === :function || def.head === :(=)) || return nothing` and handed back
`nothing` for the `:macrocall` it was given. Two halves of one feature written to different
assumptions. It now recurses through the wrapper and rebuilds it around the instrumented body.

**The docstring documented the defect.** Its table read "`Base.@kwdef`, `@inline`, `@noinline` and
the other pass-through macros | no", which is what the code did, not what the code was for. The row
is now split: the six annotating macros are observed; `@generated` (its body returns an expression)
and `Base.@kwdef` (it wraps a struct) are not, and each says why.

**The test was named for the claim and asserted something weaker.** `@testset "@inline and the mark
compose in both orders"` asserted `Set([:f, :g]) == names marked` — satisfied by a mark that can
never fire. It now asserts the observation, and a second testset pins the split that a careless fix
would break: instrumenting every macrocall would put a probe inside `@generated`'s returned
expression.

Three refusals had no test at all — measured by grepping `test/` for their message text and finding
zero hits: a wrapping macro this cannot read, a `begin` block with two definitions in it, and a bare
qualified name. Each is now pinned on the part that makes it actionable — the name of the macro it
could not read, the word `block`, `Sub.g` and the form that would work — rather than on the fact
that something was thrown.

One incidental find while pinning them: a macro that throws while expanding a `module` body run
through `include_string` comes back wrapped in `LoadError` **twice**, so the usual single `.error`
leaves a `LoadError` that reads exactly like the failure it hides.

186 behaviours, 1210 assertions, green.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
sotashimozono added a commit that referenced this pull request Sep 9, 2026
* docs: every block that shows output now produces it

Measured: 33 fenced ```julia blocks across `docs/src`, of which **12 displayed output that had
been typed by hand**. Those 12 are the ones that can be wrong, and three of them were.

All 12 now execute — `@setup` fixtures plus `@repl`/`@example` — and the build fails if their
output stops matching. Typed-output blocks remaining: 0.

What executing them found:

  * `@experimental "why" Base.sum(x::Int) = x` is documented as REFUSED. It is accepted: method
    marks on another module's generic landed in #13, and the page was never revisited. The
    refusal example is now one that is still refused (a bare `Sub.g`), rendered from the real
    message, and the accepted-forms list gains the foreign-method case with the note that it is
    reported by `contributed_methods` rather than by the name audit.
  * `mark(Archeion, :ingest)` was shown without its `signature:` line, which the real display has.
  * The `entered()` transcript carried a reason for not being executable — "Documenter evaluates
    doctests in a sandbox whose module does not print as `Main`". Measured: an `@repl` block shows
    `Main.MyModel.energy`, so the reason no longer holds. It is now `entered(MyModel)`, asked about
    a module because the process-wide answer includes every fixture the documentation built.

Two blocks show what a documentation build genuinely gets rather than what a user would, and say
so: `record`'s timings are `missing` because a docs build does not load `Profile`, and
`coverage(...)` is `missing` because it has no `--code-coverage` counters. Both are the documented
contract, now demonstrated instead of described.

`record(; paths = false)` on the observing page: a captured path is the whole call stack, and in a
docs build that starts at `_start` and runs through `makedocs`.

1253 assertions, green; docs build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: the @entered report was rendered away by Documenter, not lost by the macro

The walkthrough showed

    value = ExperimentalAPI.@entered Ising.report(βs)
    -1.8886

— the value, and nothing else. The report is the whole of what `@entered` is for.

Not the macro. `IOCapture`, which is what Documenter captures with, sees it:

    captured output = "┌ @entered S.f(1.0)   at cap.jl:8\n│   Main.S.f  ×1 — r\n└ 0 of 1 …\n"
    value           = 2.0

Measured on a minimal Documenter site, with the control that settles it:

    block                                   value    rendered
    @entered S.f(1.0)                       2.0      2.0
    println("plain println"); 99            99       99          ← the println is lost too
    println("plain println only")           nothing  plain println only

An `@example` renders the block's value when it has one and its captured output only when the
value is `nothing`. A plain `println` followed by a non-`nothing` value loses its line exactly the
same way, which is what says this is Documenter and not us.

So the walkthrough uses `@entered <io> expr` and prints the buffer — the form `observing.md`
already used for the same reason — and the page now says why, with the control, and adds that a
reader at a terminal writes the plain form. All three `@entered` blocks in the built docs now
carry their report.

1250 assertions, green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant