Skip to content

test: the case matrix, written before the implementation - #7

Merged
sotashimozono merged 11 commits into
mainfrom
spec/case-matrix
Sep 3, 2026
Merged

test: the case matrix, written before the implementation#7
sotashimozono merged 11 commits into
mainfrom
spec/case-matrix

Conversation

@sotashimozono

@sotashimozono sotashimozono commented Sep 3, 2026

Copy link
Copy Markdown
Member

Ten files under test/spec/. 174 behaviours: 54 operating today, 120 specified only (157 @test_broken assertions). The suite stays green.

This is the specification for what the package has to become, written as tests rather than as a
document, so it cannot drift away from the code.

Why @test_broken and not a TODO list

Measured before committing to the structure:

@test_broken false        ->  Broken            suite stays green
@test_broken notyet()     ->  Broken            throwing counts as broken, not as an error
@test_broken 1 == 1       ->  Error: Unexpected Pass

So the spec cannot rot in either direction. It does not block work that has not been done, and
it fails the suite the moment finished work goes unpromoted.

What is covered

The measure is distinct behaviours — one per leaf @testset. It used to be the
@test:@test_broken ratio, which moves without any implementation progress:
test_spec_declare.jl has 36 assertion lines but runs 91 assertions, because several sit inside
for mk in experimental(Declared), so a thirteenth fixture mark would buy four more passing
assertions and cover nothing new.

operating today is the column to read when judging a claim about this directory: a leaf that is
entirely @test_broken is a claim written down, not a check being run.

file behaviours operating today specified only concern
test_spec_declare.jl 11 7 4 what can carry a mark: function, method, struct, const, module, macro, extension
test_spec_dispatch.jl 14 4 10 one call site, several methods, only some marked — the branch
test_spec_docstring.jl 9 6 3 a mark and a docstring are different accounts and must coexist
test_spec_foreign.jl 14 5 9 marking a method on somebody else's generic — the QAtlas.fetch case
test_spec_forms.jl 26 15 11 the definition forms a real package hits on its second afternoon
test_spec_integration.jl 17 1 16 where the mark has to surface: docs, Aqua, releases, provenance, CI
test_spec_lifecycle.jl 15 7 8 the mark's EXIT, and an entry point that is a module rather than a function
test_spec_profile.jl 40 5 35 what a real run went through, how often, and how much of it
test_spec_propagate.jl 20 2 18 a caller that never names a marked thing still depends on it
test_spec_verify.jl 8 2 6 how well is a marked thing exercised by the tests
10 files 174 54 120

The table is generatedjulia --project=test test/spec/summary.jl — and pinned by
test/test_spec_table.jl, which fails if it goes stale. The first version of this table was
hand-written and drifted inside this very pull request: two files landed after it was typed, so it
said "nine files" when there were eleven and every count in it was wrong. A file missing from the
generator's CONCERNS, or from runtests.jl, is now an error rather than a silently missing row.

Two layers, and the measurement that put the boundary there

The goal is a tool that says where experimental code was used, and a user who learns they used
it without opting in. Those are different jobs with different budgets. 10M calls of a
realistic numeric body, Julia 1.12.2, minimum of 7–9 trials:

emitted into the body 1 thread 8 threads counts correctly?
nothing 1.00× 1.00×
set-once flag, read-mostly 1.03× 0.985× yes
counter, plain shared Ref 1.03× 3.76× no
counter, global atomic 1.17× 4.87× yes
counter, per-thread atomic 1.12× 2.79× yes
@warn, guarded so it fires once 5.65× yes
@warn maxlog=1 59.57× yes

Two results decided it. The plain counter is not merely slow in parallel — it recorded
95,406,048 of 160,000,000 calls, losing 40% to races, so it is wrong as well as expensive.
And a flag written once and only read afterwards never dirties the cache line again, which is why
it is free at eight threads while every counting scheme is not.

So presence is detected by default and costs nothing; counts, call sites and paths are opt-in.
The @warn rows are why the default notice is a summary at process exit rather than a warning at
first entry: the cost is the logging call sitting in the body, not the warning being printed, and
guarding it so that it fires once does not recover it.

The summary is observed from a child process. Asserting that an atexit handler is registered
in this one would pass for a handler that prints nothing, and reading stdout alone would pass
trivially if the notice went to stderr. The two child scripts differ only in the final call, so
a summary keyed on "this module has marks" rather than on "this run entered one" fails the
control and passes the claim.

One requirement was withdrawn

test/spec/ used to require that @experimental never wrap the call, pinned structurally.
That is gone: presence cannot be detected without emitting something into the body. What replaced
it is narrower and measured — the emitted statement must be read-mostly, must add exactly one
statement, and must not bring the logging machinery with it. The last of those is checked today,
with a macro that does log as the positive control.

Negative controls: specified, and mostly not yet operating

A spec that only asserts the positive is satisfied by a tool that always answers yes. Each group
has a control specified; most are not running, because the control is @test_broken next to
the claim it controls.

group the control operating?
propagate top_good is exactly as deep as top_bad and must come back :clean no
profile cold is marked and never called, and must be absent, not reported with count zero no
verify the fixture is exercised only partly, so a checker that always reports 100% cannot pass no
integration a settled name must get no docs note no
dispatch which() really does throw for the branching signatures the file rests on yes
forms the misuse refusals name the missing half, rather than one generic message yes
lifecycle deleting a settled name is still breaking, so isbreaking cannot answer false always yes

"no" means the assertion about the implementation does not run, because the implementation is
not there — not that the row is unchecked. The fixture premise each control rests on is pinned
live where it could be got wrong (test_spec_verify.jl:38, test_spec_dispatch.jl:93).

And the one that matters most, a rule rather than a fixture: :unknown must never be reported as
:clean. Two fixtures (Holder.f::Function, TABLE[i](x)) really can reach the marked function
while being statically invisible. Answering "no experimental dependency" there is not a weaker
claim, it is a false one.

It found two defects while being written

Both shipped, both silent, both of exactly the kind it was written to catch — a mark that records
the wrong thing rather than refusing.

1. @experimental "…" (c::C)(x) = c.k * x marks :c, and the audit compounds it.

_signame walks the :: and returns the binding on the left, which is the argument name. The
mark lands on a local that is not a binding anywhere — and because it does, the audit produces a
second wrong signal:

marks recorded    : [:c]
is :c a binding?  : false
is :C a binding?  : true
audit dangling    : [:c]      # declared, no such binding
audit unaccounted : [:C]      # public, and — per the audit — never declared

So the tool tells the author to go declare the very thing that line declares. Both halves are
pinned as current behaviour, with a broken test asserting that fixing _signame clears both.

2. A mark inside a function body was refused by Julia, pointing into this package.

syntax: unsupported `const` declaration on local variable around …/ExperimentalAPI/src/mark.jl:219

which reads as a bug in the package rather than a misuse of it — the natural next step is to file
an issue here. const in local scope fails during lowering, before any code this macro emits
can run, so no check of ours can intercept it. The one lever left is where the error points, and
that is now fixed: the expansion is built with Expr and carries the caller's LineNumberNode.

syntax: unsupported `const` declaration on local variable around …/caller_side.jl:4

The remaining half — the message still never says @experimental — stays @test_broken, with the
reason it may be unreachable recorded next to it: the only expansion that avoids const is
global, which in local scope fails silently, which is worse.

The location test goes through a real file on purpose. The same misuse written through @eval or
Core.eval, which is how every other refusal in the suite is probed, produces the message with no
location at all — so a location assertion made that way is vacuous and passes against the old
expansion too. Confirmed by reverting src/mark.jl and watching it fail.

Also in this branch

test_spec_profile.jl had a wall-clock assertion. Four routes were measured and each failed for
its own reason, so the file now carries no timing assertion — as a finding, not a gap:

route what it actually measured
a < 5b + 1e-3 the arms did different work — Sim.energy(Sim.Model(x)) against a bare call. Failed at 11.8 ms vs 2.1 ms
the same ratio, arms made identical ratio ran 0.79–3.03 over eight trials on an idle machine; a 5× threshold sits inside that
@allocated equality 0 on Julia 1.12 and 16 on 1.11 for the same code — and a counter wrapper push!ing into a warmed vector allocates zero, so it cannot see the regression it was guarding
a threshold on the figures above, now that a flag will be emitted ruled out in advance: those come from an idle machine, and the same thresholds on a shared CI runner across three OSes would be a flake generator

The third was caught by writing the positive control and watching it fail to fire.

So the claim is checked where it is exact — at the expansion, compared at the AST rather than as
strings — and what only a benchmark can establish is left to a benchmark.

Two more findings from the same benchmarking: threadid() returned 9 under -t 8, because
the interactive pool is counted separately from the default one, so per-thread storage must be
sized by maxthreadid() or the first hit from a REPL task throws BoundsError. And putting
@warn in a body costs 5.65× even when guarded to fire once — what stops the definition
inlining is the call being there at all, not the warning being printed.

sotashimozono and others added 2 commits September 3, 2026 06:15
Five files under test/spec/ enumerate what the package has to handle. Most of
it is `@test_broken`, because most of it is not built.

`@test_broken` rather than a TODO list, for a reason that was measured first:

    @test_broken false        -> Broken   (suite stays green)
    @test_broken notyet()     -> Broken   (throwing counts as broken, not error)
    @test_broken 1 == 1       -> Error: Unexpected Pass

So the spec cannot rot in either direction. It does not block work that has not
been done, and it fails the suite the moment finished work goes unpromoted.

  declare    every definition form; and the method-level unit the name-level one
             is not — `QAtlas.fetch` has 570 methods behind a name, so a
             statement about the name necessarily over-claims
  propagate  a caller two hops away, with an equally deep NEGATIVE control so a
             tool that always answers ":depends" cannot pass; the three-valued
             answer, and the two cases that must come back `:unknown` rather
             than `:clean`
  docstring  a mark and a docstring are different accounts. Pinned against
             `Base.Experimental`, whose 24 entries ARE documented — Julia itself
             marks an experimental surface and documents it
  verify     which marked definitions the suite never executes, joined from the
             marks' file:line and coverage counts. The fixture is exercised
             only PARTLY on purpose, so a checker that always reports 100%
             cannot pass
  runtime    what a real run touched. Includes a floor test that the mark does
             not wrap the call, because instrumentation must stay opt-in

Two measurements from 2026-09-03 are recorded in the file headers so the next
person does not repeat them:

  Lean 4.33.1 — `sorry` propagates: 'downstream' depends on axioms [sorryAx]
  without ever writing `sorry`. That is the model.

  Julia — the sampling profiler attributes ZERO samples to marked methods
  because inlined frames carry no MethodInstance. A custom
  `Core.Compiler.AbstractInterpreter` hooking `abstract_call_method` does work:
  inference runs before inlining. `code_typed(...; optimize=true)` sees only
  `mul_float` and finds nothing.

Currently 44 passing, 46 broken. That ratio is the progress measure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine files, 67 passing and 111 broken. The broken count is the backlog; the
passing count is what the package does today.

Added:

  forms        keyword args, parametric, varargs, return annotations, callable
               structs, constructors, operators, @generated, @kwdef, @inline
               stacking, @eval-produced definitions, closures, metadata
               validation, the same name in two modules
  foreign      marking a method on somebody else's generic. This is the
               `QAtlas.fetch` case: 570 methods, `:fetch` bound in
               AbstractQAtlas, so `audit` files it under `foreign` and says
               nothing about any of them
  profile      29 cases. Hit counts, per-method attribution, the caller that
               reached the mark, inclusive vs exclusive, the fraction of the run
               spent inside experimental code, threads, cross-process merge,
               coexistence with Profile, serialisation, and using it as a gate
  integration  docs rendering, Aqua composition, method-level release diffs, a
               provenance stamp next to a result file, CI ratchets

Every group carries its own negative control, because a spec that only asserts
the positive can be satisfied by a tool that always answers yes:

  propagate    `top_good` is exactly as deep as `top_bad` and must come back
               clean
  profile      `cold` is marked and never called, and must be ABSENT rather
               than reported with count zero
  verify       the fixture is exercised only partly, so a checker that always
               reports 100% cannot pass
  integration  a settled name must get no docs note

Two defects surfaced while writing it, both shipped, both silent:

  `@experimental "…" (c::C)(x) = c.k * x` marks `:c` — the ARGUMENT name.
  `_signame` walks the `::` and returns the binding on the left. The mark lands
  on a local that is not a binding anywhere, so it is recorded and means
  nothing. Pinned as the current behaviour plus a broken test for the right one.

  A mark inside a function body is refused by Julia rather than by this package:
  `syntax: unsupported const declaration on local variable`. Correct outcome,
  useless message — it never names `@experimental`.

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

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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

(updates on each push to this PR)

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

sotashimozono and others added 4 commits September 3, 2026 06:32
`format-check` failed while the local run said everything was clean. The
versions differed: CI resolves `version="2"` to 2.13.0, the throwaway
environment here had 2.4.0, and the two disagree about a compact
`(a; for ... end; b)` block.

The block in question is rewritten as an ordinary function rather than
reformatted, so it no longer depends on which 2.x is installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A six-agent review of this PR found, among other things, three assertions in a
spec written to prevent false greens that could not fail. Each was verified
directly before being changed.

CRITICAL — assertions that could not fail

  propagate  `isempty(filter(m -> occursin("reach", String(m.name)),
             methods(_mark!)))`. `Method.name` is the GENERIC FUNCTION's name —
             always `:_mark!`, never derived from what the body calls — so the
             filter was unconditionally empty. The assertion would have passed
             even if `_mark!` called `reach` directly. Replaced by inspecting
             `@macroexpand`, which is where the claim actually lives.

  declare    `!isempty(experimental(ExperimentalAPI; extensions=true))`.
             ExperimentalAPI marks six of its own names in `src/release.jl`, so
             a keyword accepted and then ignored satisfied it. Now asserts a
             mark whose `.mod` IS the extension.

  foreign    `all(mk -> mk.mod === Downstream, experimental_methods(Downstream))`.
             `all(f, [])` is `true`, so a stub returning `[]` would have flipped
             it to Unexpected Pass. Non-emptiness is now part of the claim.

IMPORTANT — tests that could never graduate

  `Profile.fetch()` with `Profile` imported nowhere and absent from `[extras]`:
  Broken forever for a reason unrelated to the feature. Imported, and added to
  the test target.

  `isexperimental(delicate)` where `delicate` is never marked anywhere in the
  file — true only if something else marked it. The test now marks it itself,
  through the API being specified.

  `since = "0.4.0"` throws `MethodError: Cannot convert String to
  VersionNumber` from `Mark`'s field type, not from any check in the macro. A
  real refusal would keep throwing, so "it throws" could never signal the fix
  landed. Asserts the diagnostic instead.

  Seven `@test_broken @eval module … end`. A module evaluates to a `Module`, so
  on success these report "Expression evaluated to non-Boolean", not the
  "Unexpected Pass" this directory's README promises. Each now ends in a Bool
  AND checks WHICH symbol got marked — accepting the syntax while recording the
  wrong name is the defect this file already caught once, for `(c::C)(x)`.

IMPORTANT — missing negative controls

  An `experimental()` that over-reports passed every assertion in `declare` and
  `forms`; both now check the count and the absence of deliberately unmarked
  names. The constructor test now also asserts the ARGUMENT name `:s` is not
  marked. `compare_methods` was pinned by one byte-identical assertion under two
  different claims; it is now three distinct behavioural cases with a negative
  control. The depth-limit fixture called the mark at depth 1, so an
  implementation ignoring `maxdepth` passed — a five-hop chain now forces
  truncation, and the assertion is `=== :unknown` rather than `!== :clean`.

  `Threads.@threads for _ in 1:8` runs its body 8 times whatever `nthreads()`
  is, and CI never set a thread count — so the concurrency test could not fail
  for a recorder that is not thread safe. CI now sets `JULIA_NUM_THREADS: 4`
  and the suite asserts `nthreads() > 1`, verified to fail on one thread.

Claims that would rot

  "Base.Experimental holds 24 entries" — measured 19 filtered / 13 documented on
  1.11.9; the number moves with the version AND the counting rule, and nothing
  checked it. Removed; the test pins named entries instead. The README's
  measured-count table was deleted one day earlier for the same reason.

  `QAtlas.fetch has 570 methods`, repeated in four files with no date and no way
  to re-derive it here: dated, and labelled as not re-derived.

  The AbstractInterpreter and profiler measurements now state Julia 1.12.2. The
  Lean claim three lines above was already pinned to 4.33.1; `Core.Compiler` is
  internal and is the more version-sensitive of the two.

Also: `module Forms` and `module Upstream` collided with `test/test_mark.jl` and
`test/test_audit.jl` and were silently replacing each other in `Main`; renamed.
Two `public` names that were never defined, removed. A prose grep standing in
for a codegen check, replaced. A `tempname()` read without being written,
routed through `stamp`.

425 passing, 112 broken.

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

Two more groups, both measured before being written.

**Dispatch branching** (`test_spec_dispatch.jl`). One call site, several methods,
only some of them marked — the shape that makes the method-level unit worth
having. `QAtlas.fetch` has 570 methods; a caller writes `fetch(model, quantity)`
once and a verdict about the NAME says nothing about the one that runs.

The dangerous case is a call site that cannot be pinned to a method. Measured
2026-09-03, Julia 1.12.2: for an argument typed `Union{Exact,Numerical}` or an
abstract `Kind`, the un-optimised IR shows only `(%1)(_2)` and `which(f, T)`
**throws**. An implementation that catches that and moves on reports `:clean`
about a call that reaches a marked method half the time — so there is an
explicit test that `which` throwing must not be swallowed.

Covered: the negative control (a site that can only reach settled methods must
be `:clean`), Union and abstract branching, `invoke` pinning a method dispatch
would not pick, a more specific unmarked method shadowing a marked fallback (and
the fall-through that does reach it), and that two call sites on the SAME name
get different verdicts.

**Lazy usage** (added to `test_spec_forms.jl`). The reason is the payload, so the
question is what happens without one. Measured: every lazy form is refused, but
two are refused by accident and one points the wrong way.

    @experimental :sym f(x) = x   MethodError: no method matching strip(::Symbol)
    @experimental 42   f(x) = x   MethodError: no method matching strip(::Int64)

Refused by `strip` failing inside `_reason`, with a message naming neither
`@experimental` nor `reason` — the same shape as the `since = "0.4.0"` case.

    @experimental f(x) = x        "nothing to mark — give a definition or a name"

The author DID give a definition; what is missing is the reason. The message
points at the wrong end of the call, which is how someone deletes a correct
definition trying to satisfy it.

Also asserts that a refused mark leaves the module clean — no mark recorded, no
name defined — rather than only that an exception came out.

451 passing, 125 broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cross-checked the spec against the design letter on General#166832. Ten of the
twelve claims in it already had coverage. Two did not, and both are quoted in
the new file's header because they are the author's words, not a paraphrase.

  「`sorry`がついた真偽不明の命題として e2e でコードの解析を実行できる」

`reach(f, argtypes)` starts from one function with concrete argument types.
Lean's `#print axioms` answers for any declaration, and the analogue is an entry
point that is a MODULE — "does anything this package exposes reach unvalidated
code" — or a script, which is the shape a researcher actually has. Neither was
specified. Added, with a negative control (a module with nothing marked comes
back `:clean`) and the requirement that the answer names WHICH public entries
are affected: "something in here is experimental" is not actionable for a
package with 310 public names.

  「この `@experimental` を安全に外していく、というのを中間ゴールに据えた開発」

This is the most distinctive line in the letter and the spec had nothing for it.
A mark that can only be added is a decoration; a mark with a defined exit is a
plan. Added: whether a mark is ready to be removed and on what evidence,
whether removal is a release event, that removing it flips its callers AND ONLY
its callers, that `since` is readable so "experimental" cannot quietly become
permanent, a ratchet on the mark count, and the failure mode of deleting a mark
while callers still depend on it.

The fixture is built so coverage alone cannot separate the two marks — both
`verified_now` and `still_unverified` are exercised by this suite — because if
it could, "ready to promote" would collapse into "is it tested", which is not
what the letter says.

One test promoted from `@test_broken` to `@test`: "removing a mark is reported
as not breaking" reported Unexpected Pass, because `compare`/`isbreaking`
already do this. That is the directory's mechanism working as designed. Paired
with the negative control it was missing — deleting the name outright IS
breaking, and without that the assertion would pass for an `isbreaking` that
always answers false.

459 passing, 137 broken.

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

@sotashimozono sotashimozono left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked out d526200, ran the suite, and reproduced both defects the description claims. The
central idea holds up under measurement, so the notes below are about the edges.

Suite as it stands: 460 Pass, 136 Broken, 596 total, green, Pkg.test() exit 0.

The load-bearing idea works, and it is checkable

The @test_broken register was the right call, and the reason given for it is the one that
matters: @test_broken 1 == 1 is an Error: Unexpected Pass, so the spec fails the suite when
finished work goes unpromoted. That is not a claim about intent — it is enforced, and the green
suite is the evidence. Nothing in these 136 broken assertions is already implemented.

The negative controls are well conceived. test_spec_profile.jl:66-67 states the distinction
precisely:

A recorder that lists every mark in the module passes the two tests above. This is the control
that separates "observed" from "enumerated".

That is exactly the failure a profiler of this kind falls into, and naming it in the fixture is
worth more than the assertion itself.


1. The description has drifted from its own branch

The thesis of this PR is:

written as tests rather than as a document, so it cannot drift away from the code

The description is a document, and on this branch it has drifted. Measured per file, at runtime:

file description actual
declare 10 / 10 82 / 9
forms 15 / 11 43 / 14
verify 5 / 6 11 / 6
propagate 5 / 18 11 / 19
docstring 21 / 4 25 / 4
foreign 8 / 11 8 / 10
runtime 3 / 8 4 / 8
profile 0 / 29 1 / 28
integration 0 / 14 0 / 17
dispatch absent 8 / 10
lifecycle absent 9 / 11
total 67 / 111 202 / 136

"Nine files under test/spec/" is eleven. dispatch and lifecycle (337 lines) landed after the
description was written and are wired into runtests.jl, so the suite runs strictly more than the
description accounts for.

Not a correctness problem, and PR descriptions go stale routinely. It is worth a comment only
because this PR is the argument that prose drifts and tests do not, and the prose drifted inside
the PR making the argument.

Suggestion: if the ratio is the published progress measure, generate the table. A few lines in
the test/spec/README.md build, or a recipe that prints it from a verbose=true testset, removes
the whole category.

2. "Every group has a negative control" is present tense; the controls are not active yet

test_spec_profile.jl:68:

@test_broken :cold  [h.name for h in ExperimentalAPI.record(() -> Sim.driver(M, 10))]

The control is itself @test_broken, so today it controls nothing — it is a specified control,
not an operating one. It starts protecting the moment record exists and the assertion is
promoted, which is the whole design, but until then a reader of the description believes a
guarantee is in force that is not.

profile is 29 broken to 1 passing and integration is 17 broken to 0 passing, so those two
groups currently assert nothing at all about the implementation. That is a fine state for a spec
written first; it is just a different state from what "every group has a negative control" reads
as. Suggest the future tense, or a column in the table marking which controls are live.

3. The pass:broken ratio moves without implementation progress

test_spec_declare.jl has 36 assertion lines and 91 runtime assertions. The gap is loops:

for mk in experimental(Declared)        # :107 -- 12 marks
    @test !isempty(strip(mk.reason))
    @test mk.mod === Declared
    @test mk.line > 0
    @test String(mk.file) == @__FILE__
end

Those are real property checks and worth having. But they scale with fixture size, while the
broken assertions are hand-written per behaviour. Adding a thirteenth mark to Declared adds four
passing assertions and covers nothing new, so the ratio improves without progress.

If the ratio is meant as the measure, counting distinct behaviours — one per @testset leaf,
say — is harder to inflate than counting assertions.

4. Defect 1 is worse than the description says

The description says @experimental "..." (c::C)(x) = c.k * x marks :c. Reproduced, and the
consequence goes further than a stray mark — it produces two wrong signals from one mistake:

marks recorded    : [:c]
is :c a binding?  : false
is :C a binding?  : true
audit dangling    : [:c]
audit unaccounted : [:C]

So the author marks the callable, and the audit then reports that C is unaccounted for --
the exact opposite of what they declared, on the name they were declaring it about. A user who
trusts the audit is told to go document a thing they just marked.

Worth stating in the broken test so whoever implements it knows both halves have to move, not
only _signame.

5. Defect 2 points into this package, not at the caller

The description says the error "points at a line the author did not write". Reproduced, and it is
sharper than that — it points at a line inside ExperimentalAPI:

syntax: unsupported `const` declaration on local variable
  around .../ExperimentalAPI/src/mark.jl:207

The string experimental does not appear anywhere in the message. So the user experience of
misusing @experimental is a syntax error attributed to this package source, which reads as a bug
in the package rather than a misuse of it — the most expensive form this could take, since the
natural next step is to file an issue here.

Given that the package elsewhere spends real effort on messages that name the fix
(Name it instead: @experimental "why" Inner), this one is out of character rather than a missing
nicety.


Smaller

  • test/spec/README.md is 56 lines and is not linked from the top-level README.md. Since it
    carries the rationale for the register, a one-line pointer would help a reader who lands on the
    repo root and finds 136 broken tests without context.
  • test_spec_integration.jl asserts about docs, Aqua, releases, provenance and CI. Those have
    external dependencies (a docs build, a registry) that the other files do not. Worth confirming
    they can be made to run in CI at all before promoting them, so they do not become the group that
    stays broken because it cannot be otherwise.

sotashimozono and others added 5 commits September 3, 2026 08:02
Six agents, two rounds. This round found a defect in `src/`, three false greens
I introduced in the files added since round one, and a set of design-level gaps
in the API the spec pins by assertion. Every finding was verified directly
before being acted on.

SHIPPED CODE

  `Mark` had no inner constructor, so `Mark(Main, :x, "", …)` built an
  empty-reason mark. The check lived only in `_reason`, which only the macro
  calls — and `Mark` is `public`. The reason is the payload, so the invariant
  now lives in the type: every future construction route (a method-level
  `mark_method!`, a deserialised snapshot) gets it without remembering to ask.

FALSE GREENS, SAME CLASSES AS ROUND ONE, REINTRODUCED IN THE NEW FILES

  `isa Any` twice in the lifecycle spec. `x isa Any` is true of every Julia
  value, so both would have reported Unexpected Pass for a stub returning
  `nothing`. Replaced with the property the caller actually needs.

  `reach_script(tempname())` — `tempname()` creates no file, so this would have
  thrown `SystemError` forever for a reason unrelated to the feature. Exactly
  the defect fixed one commit earlier for `stamp`. The file is written now.

  `@test_throws Exception which(...)` — satisfied by a typo raising
  `UndefVarError` as well as by the ambiguity the file is about. Pinned to
  `ErrorException` with "ambiguous" in the message, measured.

FIXTURES THAT VARIED ON THE WRONG AXIS

  The two lifecycle marks differed only in whether `tracking` was set, so
  `ready_to_promote(m,n) = mark(m,n).tracking !== nothing` — a rule with no
  relationship to "has the reason been discharged" — satisfied both
  assertions. A third mark now carries a tracking link and is still not ready.

  The `invoke` fixture pinned a method ordinary dispatch would have picked
  anyway, so an invoke-blind analysis passed. It now forces the marked
  `::Integer` fallback from an `Int`, which dispatch sends to the unmarked
  `::Int`.

  `occursin("k", string(u))` — a one-character needle matching "unknown call
  site" and "package boundary" alike. Asks for structured fields now.

  `compare_methods` used `"methods"` in one testset and `"stable_methods"` in
  the other two, so "same shape, opposite verdict" was false. One schema,
  mirroring `snapshot`'s `"stable"`/`"experimental"` pair.

  Six lazy-usage forms all checked with `@test_throws Exception` collapse into
  three message templates; one generic `ArgumentError("invalid usage")` passed
  all six, in a section whose stated purpose is that the message points the
  right way. Each case pins its phrase.

  `max_marks`, `affected_entries` and `dependents` were pinned by shape or
  non-emptiness only — an ignored keyword, or a walk reporting every public
  name, passed all three. Each now has the exclusion its fixture already
  contained but never used.

DESIGN THE SPEC WAS PINNING BY ACCIDENT

  `Reach` appeared nowhere: the result was fixed by field name alone, so a
  NamedTuple satisfied four files and `verdict` could be duck-typed. Now
  pinned nominally, with `verdict` required to be DERIVED rather than stored —
  following `isbreaking(d::Diff)`, which is why a `Diff` cannot claim "not
  breaking" while carrying a removal. `:clean` with a non-empty `.unresolved`
  is the state this whole area exists to forbid.

  Every existing verdict in this package is a named predicate — `isbreaking`,
  `isexperimental`, `isdocumented` — never a comparison the caller writes out.
  The spec hand-wrote `verdict(...) === :clean` 26 times. A boolean gate is now
  specified alongside.

  `:unknown`'s algebra was never stated, though `reach(Module)` cannot be
  implemented without folding verdicts. Specified: `:unknown` absorbs `:clean`,
  `:depends` absorbs `:unknown`, and folding is order-independent.

  `Audit` was growing three fields pinned by `hasproperty` from three files
  written without cross-referencing each other; the partition invariant they
  pressure is now asserted.

  Attaching `@experimental` at one method's definition marks the NAME —
  `_signame` throws the argument types away. The dispatch fixture read as if it
  scoped. Stated, with `mark_method!` named as the actual route.

STRUCTURE

  `test_spec_runtime.jl` is gone. Seven of its ten testsets restated
  `test_spec_profile.jl` on a strictly smaller fixture; its two real
  measurements are folded in. Four unused imports removed, three thrice-stated
  comments reduced to one, and the repeated Module+eval+LoadError plumbing in
  the forms spec is a `probe` helper.

486 passing, 150 broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…here the refusal points

Five findings, all reproduced before being acted on.

1. The description had drifted from its own branch, inside the change that argues prose drifts
   and tests do not. Fixed at the source rather than by retyping it: `test/spec/summary.jl`
   generates the table by reading the directory, `test/test_spec_table.jl` pins README against
   it, and both a spec file missing from `CONCERNS` and one missing from `runtests.jl` are now
   errors. The hand-written table lost `dispatch` and `lifecycle` exactly that way.

2. "Every group has a negative control" was present tense about controls that are themselves
   `@test_broken`. The README now separates specified from operating, per group, and says which
   fixture premises are pinned live so the specified controls are not resting on nothing.

3. The published measure was the `@test`:`@test_broken` ratio, which moves without any
   implementation progress: 36 assertion lines in `test_spec_declare.jl` run 91 assertions
   because several sit inside a loop over the fixture's marks. The measure is now distinct
   behaviours — one per leaf `@testset` — and `test_spec_table.jl` pins that a 100-iteration
   loop counts as one behaviour, not one hundred.

4. Defect 1 emits two wrong signals, not one. `(c::C)(x) = c.k * x` marks `:c`, so the audit
   reports `:c` dangling AND `:C` unaccounted — it tells the author to declare the very thing
   that line declares. Both halves are pinned, with the corrected behaviour as one broken test
   asserting they clear together.

5. Defect 2 pointed into this package: `ExperimentalAPI/src/mark.jl:219`, reading as a bug here
   rather than a misuse. `const` in local scope fails during lowering, before any emitted code
   runs, so no check of ours can intercept it — but the expansion is now built with `Expr` and
   carries the caller's `LineNumberNode`, so the message names the line the author wrote. The
   remaining half (naming `@experimental`) stays broken, with the reason it may be unreachable.

Also: the wall-clock ratio in `test_spec_profile.jl` was measuring the fixture, not the mark.
Its two arms did different work — `Sim.energy(Sim.Model(x))` against a bare call — and it failed
at 11.8 ms vs 2.1 ms. Making the arms identical does not rescue it: the ratio over eight trials
on an idle machine ran 0.79 to 3.03, so the 5x threshold sat inside the noise. Replaced with an
exact allocation check, with the structural `@macroexpand` test named as the stronger of the two.

Smaller: `test/spec/README.md` is linked from the top-level README, and
`test_spec_integration.jl` records which of its assertions need a test dependency this package
does not have (one: Documenter) so the group is not assumed to be blocked on infrastructure.

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

Two CI failures, both defects I introduced in the previous commit.

`julia 1.11 — ubuntu-latest`: `@allocated(loop(unmarked, xs)) == 0` came back 16. The absolute
figure was the number Julia 1.12 produced on my machine, written down as if it were a property of
the code. Chasing it produced the real finding: `@allocated` cannot see the regression it was
guarding against. A counter wrapper `push!`ing into a vector whose capacity the warm-up call
already grew allocates **zero** — measured, as a positive control that failed to fire. So the
allocation route is not a weaker check, it is a check of something else.

The claim — `@experimental` emits the definition unchanged — is exact at the expansion and only
ever approximate at run time, so it is now checked there and only there: the method bodies of the
marked expansion must equal those of the bare one, compared at the AST rather than as strings
(the expansion carries `Expr(:escape, …)`, and the printed forms differ when the bodies do not).
`WrapControl.@wrapping` is a macro that does wrap the call, and the same comparison must reject
it — without that, `!occursin(…)` is satisfied by an expansion that dropped the definition.

All three run-time routes that were tried are recorded in the file with what each measured, so
the absence of a timing assertion reads as a finding rather than as a gap.

`julia 1.12 — windows-latest`: git checks the README out with CRLF, so the generated-table
comparison was between line endings. Normalised on both sides.

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

The goal was restated: a tool that says WHERE experimental code was used, and a user who finds
out they used it WITHOUT opting in. The second half contradicted three requirements this file
already carried, so the boundary was measured instead of argued. 10M calls of a realistic numeric
body, Julia 1.12.2, minimum of 7-9 trials:

  | emitted into the body       | 1 thread | 8 threads | counts correctly? |
  | nothing                     |  1.00x   |  1.00x    |         -         |
  | set-once flag, read-mostly  |  1.03x   |  0.985x   |        yes        |
  | counter, plain shared Ref   |  1.03x   |  3.76x    |      **no**       |
  | counter, global atomic      |  1.17x   |  4.87x    |        yes        |
  | counter, per-thread atomic  |  1.12x   |  2.79x    |        yes        |
  | @warn, guarded, fires once  |  5.65x   |     -     |        yes        |
  | @warn maxlog=1              | 59.57x   |     -     |        yes        |

Two results decided the split. The plain counter is not merely slow in parallel — it recorded
95,406,048 of 160,000,000 calls, losing 40% to races, so it is wrong as well as expensive. And a
flag written once and only read afterwards never dirties the cache line again, which is why it is
free at eight threads while every counting scheme is not.

So presence is detected by default and costs nothing; counts, call sites and paths are opt-in.
The @warn rows are why the default notice is a summary at process exit rather than a warning at
first entry: the cost is the logging call sitting in the body, not the warning being printed, and
guarding it so it fires once does not recover it.

WITHDRAWN: "the mark does not wrap the call", pinned structurally one commit ago. Presence cannot
be detected without emitting something into the body. What replaces it is narrower and measured —
the emitted statement must be read-mostly, must add exactly one statement, and must not bring the
logging machinery with it. The last is checked today, with a macro that does log as the positive
control. "Recording is off by default" and "the run pays nothing when recording is off" are
likewise replaced by the two-layer pair.

The exit summary is observed from a child process, because asserting that an atexit handler is
registered in this one would pass for a handler that prints nothing, and reading stdout alone
would pass trivially if the notice went to stderr. The two child scripts differ only in the final
call, so a summary keyed on "this module has marks" fails the control and passes the claim.

Also recorded, both found while benchmarking: threadid() returned 9 under `-t 8` because the
interactive pool is counted separately, so per-thread storage must be sized by maxthreadid(); and
a fourth wall-clock route was ruled out in advance, since these figures come from an idle machine
and the same thresholds on a shared CI runner across three OSes would be a flake generator.

174 behaviours, 54 operating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
738 comment lines to 384. What went, in every file: the measurement narratives, the dates, the
history of what a testset used to require, and the justifications for decisions the testset name
already states. What stayed: the scope of the behaviour being pinned, and the boundary a reader
needs in order to not weaken it — which fixture axis varies, why an assertion is not `isa Any`,
what a control rejects.

The measurements are not lost; they are in `test/spec/README.md`, the pull request and the commit
messages, which is where they belong. A comment that recounts how a number was arrived at is a
comment about the process, not about the code under it.

Verified by comparing ASTs with `LineNumberNode`s stripped: every file is identical except three
docstrings in `test_spec_profile.jl` and one in `summary.jl`, all shortened deliberately. Not one
assertion changed, and the suite reports the same 505 / 160 / 0 as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sotashimozono
sotashimozono merged commit 047da55 into main Sep 3, 2026
13 checks passed
@sotashimozono
sotashimozono deleted the spec/case-matrix branch September 3, 2026 09:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant