Skip to content

fix(hot-reload): make it work on Linux, and pin down the one-flecs invariant - #1079

Merged
andrewgazelka merged 11 commits into
mainfrom
hotreload
Jul 30, 2026
Merged

fix(hot-reload): make it work on Linux, and pin down the one-flecs invariant#1079
andrewgazelka merged 11 commits into
mainfrom
hotreload

Conversation

@andrewgazelka

Copy link
Copy Markdown
Member

Makes crates/hyperion-hot-reload work on Linux, and establishes the linking invariant the whole design rests on. Groundwork for hot reloading a game module on ix apply without disconnecting players; the deployment half is designed and written up but deliberately not in this PR.

It did not work on Linux, in two quiet ways

docs/hot-reload.md said "only tested on aarch64-darwin". That undersold it.

Exports. rustc links a dylib with its own anonymous version script ending in local: *, which demotes every symbol it did not generate. flecs's C symbols arrived with DEFAULT visibility and LOCAL binding — present and unreachable — so the demo host would not even link:

rust-lld: error: undefined symbol: ecs_progress
rust-lld: error: undefined symbol: ecs_ensure_id

--export-dynamic and --export-dynamic-symbol=ecs_* both leave the exported count at exactly 0; a version-script demotion is not something either can reverse. A second version script works, because ld merges them and an explicit pattern beats a * wildcard. Before: 9001 exported, 0 of them ecs_*. After: 10717 exported, 666 of them ecs_*, ecs_init GLOBAL.

One flecs, or the world is indexed two ways. flecs_ecs's derive emits a static INDEX per component type, initialised from a process-global pool, and that index is a slot in the world's component array. The flecs_manual_registration note in the doc is about the id being per-world; the index is not. Two copies of flecs_ecs is two pools — and nothing detects it. AbiToken passes, no error is raised, and both sides stay internally consistent while disagreeing about which slot is which.

What this PR contains

  • crates/hyperion-hot-reload/build.rs — the ELF version script (ENG-11272).
  • crates/hyperion-hot-reload/demo/index-probe-{host,module} — a probe for the shared-pool invariant.
  • Repin flecs_ecs onto andrewgazelka/Flecs-Rust@f09dc53, which gives that crate crate-type = ["dylib", "rlib"] and moves the flecs export script to where the dylib is actually produced.
  • docs/hot-reload.md — the findings, the traps, the build recipe, and the deploy design.

The probe is behavioural, because the two obvious tests both lie

Recorded in the code and the doc, because both look like success:

  • Comparing ecs_init as usize across the boundary reports a mismatch even when the copy is shared. An executable taking the address of a dynamically-linked function gets its own PLT stub. Measured shared and separate copies; both printed different addresses.
  • Comparing one type's index for equality passes when nothing is shared. Two independent pools each start at 1, so the first type registered on each side reads 1 and 1. This is exactly what the probe reported before the module referenced the runtime crate at all.

Allocation order cannot coincide. With one pool an index taken in the module is strictly greater than every index the host took first.

Measurements

hyperion as a plain rlib — separate pools. The host creates the second copy, pulling flecs_ecs in through hyperion's rlib while the module resolves it from the runtime dylib; a module that touches no hyperion component does not avoid this:

host indices: [1, 2, 3, 4] (max 4)
module's own type index: 1
hyperion::simulation::Position index: host 4, module 2
SHARED_POOL=false

With flecs_ecs and hyperion both dylibs, against the real pin with no local patch:

host indices: [1, 2, 3, 4] (max 4)
module's own type index: 5
hyperion::simulation::Position index: host 4, module 4
SHARED_POOL=true
SHARED_HYPERION_INDEX=true
PROBE_OK

demo.sh on x86_64-linux now matches darwin case for case: code-only change accepted with state intact, layout change refused with the world still ticking on the previous build, migration accepted rewriting 3 instances (21u32 becoming 22.0f32, converted rather than reinterpreted). Exit 0.

Negative control: reverting build.rs reproduces the link failure, so the fix is load-bearing rather than incidental.

Verified on dev-compute-6 (x86_64-linux, rustc 1.99.0-nightly dc3f85158) and re-verified on aarch64-darwin.

What is NOT in this PR, stated plainly

  • hyperion is not yet crate-type = ["dylib", "rlib"]. The measurement above required it, but it is not part of this change, because making it a dylib needs -C prefer-dynamic -C link-arg=-Wl,--undefined-version -C link-arg=-Wl,--allow-shlib-undefined across the build and that is a packaging decision, not a one-line edit. Recipe and rationale are in the doc.
  • No hyperion module is reloadable. No export_module!, no host/rules split of smash, no reload loop. app.run() is still flecs's own main loop with no per-tick hook.
  • The deployment half is designed, not shipped. reloadTriggers, the /etc indirection and the ExecReload client are written up in the doc and not built.
  • The probe fails on any build that does not use the dylib recipe. It is a binary, not a test, so nothing runs it in CI today. It should become a Linux check once hyperion is a dylib; adding it before then would just be red.
  • The --allow-shlib-undefined requirement has a tidier fix I did not take. simulation/metadata/mod.rs:212 hand-writes impl PartialOrd for $name where $type: PartialOrd, unsatisfiable for 7 metadata types because glam's Quat and Vec3 have no PartialOrd. rustc never codegens those partial_cmp bodies but still exports them. They can never be called, so allowing them undefined is sound — but removing the blanket impl would remove the need for the flag.

Related: ENG-11272 (this), ENG-11279 (devShell cannot build smash on Linux; jemalloc 5.3.1 vs GCC 15 — pre-existing, unrelated, does not affect the nix build path).

🤖 Generated with Claude Code

--export-dynamic cannot undo what rustc does to a dylib. rustc links one
with its own anonymous version script ending in local: *, which demotes
every symbol it did not generate, so flecs C symbols sit in the object
with DEFAULT visibility and LOCAL binding -- present and unreachable.
Measured on x86_64-linux: 9001 exported symbols, zero of them ecs_*,
ecs_init reading FUNC LOCAL DEFAULT. --export-dynamic-symbol=ecs_* is
equally powerless against a version-script demotion; both were tried.

The crate was therefore not merely untested on Linux, it did not work
there at all. The demo host fails to link:

  rust-lld: error: undefined symbol: ecs_progress
  rust-lld: error: undefined symbol: ecs_ensure_id

ld merges multiple version scripts and an explicit pattern beats a *
wildcard, so a second script naming these globs promotes exactly them
and leaves rustcs own exports alone. After: 10717 exported, 666 of them
ecs_*, ecs_init GLOBAL, and demo.sh runs all three cases on
x86_64-linux with the same output as aarch64-darwin -- code-only change
accepted with state intact, layout change refused with the world still
ticking on the old build, migration accepted rewriting 3 instances.

Darwin re-verified unchanged.
…dylib boundary

Everything the reload gate does assumes the host and a module dylib draw
component indices from one pool. Nothing in the reload path checks it,
and both sides are internally consistent when they do not, so the world
is simply indexed two different ways and no error is raised.

The probe is behavioural rather than an address comparison. Comparing
ecs_init as usize across the boundary reports a mismatch even when the
copy is shared, because an executable taking the address of a
dynamically linked function gets its own PLT stub; measured that trap
directly, with separate and shared copies both printing different
addresses. Allocation order cannot be faked: with one pool, an index
taken in the module is strictly greater than every index the host took
first.

It also catches a false positive worth recording. Before the module
referenced the runtime crate at all it linked its own static copy of
everything, and two independent pools each starting at 1 made
hyperion::simulation::Position read index 1 on both sides. That looks
exactly like success. The measurement that distinguishes them is
allocation order, not equality.

Result with hyperion as a plain rlib -- separate pools:

  host indices: [1, 2, 3, 4] (max 4)
  module own type index: 1
  Position index: host 4, module 2
  SHARED_POOL=false

The host is what creates the second copy: it drags flecs_ecs in through
hyperions rlib while the module resolves it from the runtime dylib.

Result with flecs_ecs and hyperion both dylibs -- one pool:

  host indices: [1, 2, 3, 4] (max 4)
  module own type index: 5
  Position index: host 4, module 4
  SHARED_POOL=true
  SHARED_HYPERION_INDEX=true
  PROBE_OK

Verified on x86_64-linux (dev-compute-6).
…eploy shape

The document claimed the crate was merely untested on Linux. It did not
work there, in two independent ways, and both are quiet: exports demoted
to LOCAL by rustcs own version script, and a second flecs copy that
leaves host and module indexing one world two different ways with no
error from anything.

Records the two measurements that give the wrong answer (comparing
ecs_init addresses across a PLT boundary, and comparing one types index
for equality when two pools both start at 1), because both look like
success.

Also writes down the deploy mechanism: NixOS reloads rather than
restarts a unit when only X-Reload-Triggers changed, which is the whole
basis for a game-logic change reaching a running server without
disturbing a player. States plainly that it is not built.
andrewgazelka/Flecs-Rust f09dc53 gives flecs_ecs crate-type
["dylib", "rlib"] and a build script re-exporting flecs C symbols from
the dylib. Hot reloading needs exactly one copy of that crate in the
process, because it owns the process-global pool handing out each
component types index into a worlds component array.

Verified against the pin with no local patch, on x86_64-linux: host took
component indices 1..4, the module then took 5, and
hyperion::simulation::Position read index 4 on both sides. PROBE_OK.

Darwin re-verified: demo.sh exits 0 with the same three verdicts.
@andrewgazelka andrewgazelka changed the title hot-reload: make it work on Linux, and pin down the one-flecs invariant fix(hot-reload): make it work on Linux, and pin down the one-flecs invariant Jul 29, 2026
@github-actions github-actions Bot added the fix label Jul 29, 2026
@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  18.6 ns ...  18.7 ns ]      +0.21%
ray_intersection/aabb_size_1                       [  18.8 ns ...  18.7 ns ]      -0.21%
ray_intersection/aabb_size_10                      [  18.6 ns ...  18.7 ns ]      +0.09%
ray_intersection/ray_distance_1                    [   1.3 ns ...   1.3 ns ]      -0.60%
ray_intersection/ray_distance_5                    [   1.3 ns ...   1.3 ns ]      +0.12%
ray_intersection/ray_distance_20                   [   1.3 ns ...   1.3 ns ]      +0.24%
overlap/no_overlap                                 [  15.4 ns ...  15.5 ns ]      +0.17%
overlap/partial_overlap                            [  15.5 ns ...  15.5 ns ]      +0.16%
overlap/full_containment                           [  14.5 ns ...  14.5 ns ]      -0.33%
point_containment/inside                           [   5.4 ns ...   5.4 ns ]      -0.06%
point_containment/outside                          [   5.7 ns ...   5.7 ns ]      -0.02%
point_containment/boundary                         [   5.4 ns ...   5.4 ns ]      -0.00%

Comparing to dd8311f

Allows print_stdout in the probe host, matching the demo host: a binary
whose whole purpose is reporting a measurement.

Drops the hyperion-hot-reload dependency from both probe crates. It was
unused by cargo-machete's reckoning and, more to the point, the comment
claiming that reference was what made the two sides share one flecs was
wrong once flecs_ecs became a dylib. Removing it entirely leaves the
probe passing:

  host indices: [1, 2, 3, 4] (max 4)
  module own type index: 5
  Position index: host 4, module 4
  PROBE_OK

The doc comment now says what actually makes the pool shared, and
records that a single index reading equal on both sides is not evidence:
two separate pools both start at 1.
@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  18.6 ns ...  18.6 ns ]      +0.02%
ray_intersection/aabb_size_1                       [  18.6 ns ...  18.7 ns ]      +0.19%
ray_intersection/aabb_size_10                      [  18.6 ns ...  18.7 ns ]      +0.24%
ray_intersection/ray_distance_1                    [   1.3 ns ...   1.3 ns ]      -0.14%
ray_intersection/ray_distance_5                    [   1.3 ns ...   1.3 ns ]      +0.22%
ray_intersection/ray_distance_20                   [   1.3 ns ...   1.3 ns ]      +0.20%
overlap/no_overlap                                 [  15.9 ns ...  15.9 ns ]      +0.04%
overlap/partial_overlap                            [  15.9 ns ...  16.0 ns ]      +0.71%*
overlap/full_containment                           [  14.5 ns ...  14.9 ns ]      +2.79%*
point_containment/inside                           [   5.4 ns ...   5.4 ns ]      -0.17%
point_containment/outside                          [   5.7 ns ...   5.7 ns ]      +0.25%
point_containment/boundary                         [   5.4 ns ...   5.4 ns ]      +0.13%

Comparing to dd8311f

The repin commit left `crate-type = ["dylib", "rlib"]` on `hyperion`.
That is required for the shared component-index pool, but only together
with the rest of the recipe -- `-C prefer-dynamic`,
`-Wl,--undefined-version`, `-Wl,--allow-shlib-undefined` -- which a
plain `cargo test` does not use. Without them the dylib fails to link:

  ld: Undefined symbols for architecture arm64
  ld: symbol(s) not found for architecture arm64

so the whole workspace stopped building. `cargo test --workspace` now
passes again.

Making `hyperion` a dylib is a packaging decision affecting every
consumer, and it does not belong in a fix PR. The recipe and the
measurements that justify it are in docs/hot-reload.md.

The probe consequently cannot pass on a default build, which is the
honest state rather than a regression: it exists to detect exactly the
configuration the repo currently has. Its failure message now says so
and points at the recipe instead of just reporting two numbers.
@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  18.7 ns ...  18.7 ns ]      -0.21%
ray_intersection/aabb_size_1                       [  18.8 ns ...  18.8 ns ]      +0.16%
ray_intersection/aabb_size_10                      [  18.8 ns ...  18.7 ns ]      -0.23%
ray_intersection/ray_distance_1                    [   1.3 ns ...   1.3 ns ]      -0.21%
ray_intersection/ray_distance_5                    [   1.3 ns ...   1.3 ns ]      +0.13%
ray_intersection/ray_distance_20                   [   1.3 ns ...   1.3 ns ]      -0.34%
overlap/no_overlap                                 [  15.6 ns ...  15.6 ns ]      -0.09%
overlap/partial_overlap                            [  15.9 ns ...  15.6 ns ]      -1.82%
overlap/full_containment                           [  14.6 ns ...  14.6 ns ]      +0.00%
point_containment/inside                           [   5.4 ns ...   5.4 ns ]      -0.03%
point_containment/outside                          [   5.7 ns ...   5.7 ns ]      +0.29%
point_containment/boundary                         [   5.4 ns ...   5.4 ns ]      +0.06%

Comparing to dd8311f

A rules-only change is roughly 2 s of compile and tens of milliseconds
of reload; touching the engine is 4.42 s of compile and costs the
process, the world and every connected player. That contrast is the
argument for the whole design, and it was not written down anywhere.

States what the numbers are not: debug rather than release, a whole
binary relink rather than a rules dylib, and a 165 KB probe module
rather than smash.
@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  22.0 ns ...  22.0 ns ]      +0.06%
ray_intersection/aabb_size_1                       [  22.0 ns ...  22.0 ns ]      +0.11%
ray_intersection/aabb_size_10                      [  22.0 ns ...  22.0 ns ]      +0.14%
ray_intersection/ray_distance_1                    [   0.9 ns ...   0.9 ns ]      -0.05%
ray_intersection/ray_distance_5                    [   0.9 ns ...   0.9 ns ]      +0.35%
ray_intersection/ray_distance_20                   [   0.9 ns ...   0.9 ns ]      -0.27%
overlap/no_overlap                                 [  14.6 ns ...  14.6 ns ]      -0.05%
overlap/partial_overlap                            [  15.0 ns ...  15.1 ns ]      +0.36%
overlap/full_containment                           [  14.0 ns ...  14.1 ns ]      +0.20%
point_containment/inside                           [   5.9 ns ...   5.9 ns ]      +0.37%
point_containment/outside                          [   5.9 ns ...   5.9 ns ]      +0.13%
point_containment/boundary                         [   5.9 ns ...   6.0 ns ]      +0.59%

Comparing to dd8311f

@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  18.6 ns ...  18.7 ns ]      +0.31%
ray_intersection/aabb_size_1                       [  18.6 ns ...  18.6 ns ]      -0.17%
ray_intersection/aabb_size_10                      [  18.7 ns ...  18.6 ns ]      -0.18%
ray_intersection/ray_distance_1                    [   1.3 ns ...   1.3 ns ]      +0.17%
ray_intersection/ray_distance_5                    [   1.3 ns ...   1.3 ns ]      +0.38%
ray_intersection/ray_distance_20                   [   1.3 ns ...   1.3 ns ]      -0.16%
overlap/no_overlap                                 [  15.9 ns ...  15.8 ns ]      -0.24%
overlap/partial_overlap                            [  15.5 ns ...  15.5 ns ]      +0.16%
overlap/full_containment                           [  14.6 ns ...  14.6 ns ]      -0.05%
point_containment/inside                           [   5.5 ns ...   5.5 ns ]      -0.15%
point_containment/outside                          [   5.7 ns ...   5.7 ns ]      -0.10%
point_containment/boundary                         [   5.4 ns ...   5.4 ns ]      +0.35%

Comparing to dd8311f

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.59%. Comparing base (dd8311f) to head (df28ffa).

Files with missing lines Patch % Lines
...erion-hot-reload/demo/index-probe-host/src/main.rs 0.00% 35 Missing ⚠️
...rion-hot-reload/demo/index-probe-module/src/lib.rs 0.00% 6 Missing ⚠️
@@            Coverage Diff             @@
##             main    #1079      +/-   ##
==========================================
- Coverage   54.65%   54.59%   -0.07%     
==========================================
  Files         361      363       +2     
  Lines       33257    33298      +41     
  Branches     1259     1259              
==========================================
  Hits        18178    18178              
- Misses      14793    14834      +41     
  Partials      286      286              
Files with missing lines Coverage Δ
...rion-hot-reload/demo/index-probe-module/src/lib.rs 0.00% <0.00%> (ø)
...erion-hot-reload/demo/index-probe-host/src/main.rs 0.00% <0.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The repin changed the Cargo.lock source string, and outputHashes is
keyed by that exact string, so every nix check failed to evaluate:

  error: outputHashes is missing hashes for git source strings in
  Cargo.lock: git+https://github.com/andrewgazelka/Flecs-Rust?rev=f09dc53...

sha256-DlMOSY7NyoPoR8w4yswm3O97BegcNWcLl/fW3wOAmRs= from
nix-prefetch-git --fetch-submodules at that rev.
@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  18.7 ns ...  18.7 ns ]      +0.22%
ray_intersection/aabb_size_1                       [  18.7 ns ...  18.8 ns ]      +0.13%
ray_intersection/aabb_size_10                      [  18.7 ns ...  18.7 ns ]      +0.05%
ray_intersection/ray_distance_1                    [   1.3 ns ...   1.3 ns ]      -0.17%
ray_intersection/ray_distance_5                    [   1.3 ns ...   1.3 ns ]      +0.29%
ray_intersection/ray_distance_20                   [   1.3 ns ...   1.3 ns ]      -0.24%
overlap/no_overlap                                 [  15.6 ns ...  15.6 ns ]      -0.39%
overlap/partial_overlap                            [  16.0 ns ...  16.0 ns ]      -0.12%
overlap/full_containment                           [  14.5 ns ...  14.5 ns ]      -0.02%
point_containment/inside                           [   5.4 ns ...   5.4 ns ]      -0.16%
point_containment/outside                          [   5.7 ns ...   5.7 ns ]      -0.02%
point_containment/boundary                         [   5.5 ns ...   5.5 ns ]      -0.11%

Comparing to dd8311f

…iour-only argument

Three gaps a stranger would have hit.

A behaviour-only module does not avoid the shared-pool requirement.
The registration/behaviour split in CLAUDE.md makes it look like it
should: a library that registers nothing cannot collide with anything.
But registering a component and looking one up are different
operations and only the first is avoided -- a system's query still
resolves T through T::index(). The probe module registers nothing at
all and still read Position as index 2 where the host had it at 4. The
split is worth keeping for the hazard it does address, which is
component layout, and that is now said explicitly along with the
boundary it implies: changing what a system does is a reload, changing
a component type is a rebuild and a restart.

What makes the pool shared is the dependency being a dylib, not a
consumer referencing it. An earlier probe carried a call to
AbiToken::current() with a comment claiming otherwise; removing the
dependency entirely leaves the probe passing.

And the four remaining steps are now written down in order, with the
packaging step named as the risky one and the reason why: every
measurement here came from a cargo build, not a nix one.

Also records that adoption needs no scheduled restart -- the fleet
already restarts for version bumps, so the split host takes effect on
the next apply happening for its own reasons -- and that the build tree
on dev-compute-6 was deleted when the node was released.
@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  17.2 ns ...  17.2 ns ]      +0.26%
ray_intersection/aabb_size_1                       [  17.6 ns ...  17.5 ns ]      -0.52%
ray_intersection/aabb_size_10                      [  17.3 ns ...  17.2 ns ]      -0.19%
ray_intersection/ray_distance_1                    [   1.5 ns ...   1.5 ns ]      -0.34%
ray_intersection/ray_distance_5                    [   1.5 ns ...   1.5 ns ]      +0.30%
ray_intersection/ray_distance_20                   [   1.5 ns ...   1.5 ns ]      +0.04%
overlap/no_overlap                                 [  15.8 ns ...  15.8 ns ]      -0.00%
overlap/partial_overlap                            [  15.9 ns ...  15.9 ns ]      +0.09%
overlap/full_containment                           [  14.8 ns ...  14.9 ns ]      +0.17%
point_containment/inside                           [   6.0 ns ...   6.0 ns ]      -0.24%
point_containment/outside                          [   6.0 ns ...   6.0 ns ]      +0.13%
point_containment/boundary                         [   6.0 ns ...   6.1 ns ]      +0.19%

Comparing to dd8311f

Making flecs_ecs a dylib means it emits two artifacts on every build.
I tried to measure whether that costs build time and could not confirm
the comparison: 8875 ms with both against 8817 ms with the rlib alone
is within noise, but a leftover dylib in the target directory means the
rlib-only configuration may never have taken effect.

Recording it as unmeasured rather than as zero, because the difference
between those two claims is what decides whether the next person needs
to look.
@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  18.7 ns ...  18.8 ns ]      +0.07%
ray_intersection/aabb_size_1                       [  18.7 ns ...  18.7 ns ]      -0.05%
ray_intersection/aabb_size_10                      [  18.7 ns ...  18.7 ns ]      +0.02%
ray_intersection/ray_distance_1                    [   1.3 ns ...   1.3 ns ]      +0.02%
ray_intersection/ray_distance_5                    [   1.3 ns ...   1.3 ns ]      +0.09%
ray_intersection/ray_distance_20                   [   1.3 ns ...   1.3 ns ]      -0.08%
overlap/no_overlap                                 [  16.5 ns ...  16.4 ns ]      -0.06%
overlap/partial_overlap                            [  16.1 ns ...  16.1 ns ]      -0.10%
overlap/full_containment                           [  14.7 ns ...  14.7 ns ]      -0.05%
point_containment/inside                           [   5.4 ns ...   5.4 ns ]      +0.43%
point_containment/outside                          [   5.7 ns ...   5.7 ns ]      -0.25%
point_containment/boundary                         [   5.4 ns ...   5.4 ns ]      -0.32%

Comparing to dd8311f

@andrewgazelka
andrewgazelka merged commit 312bed6 into main Jul 30, 2026
10 of 11 checks passed
@andrewgazelka
andrewgazelka deleted the hotreload branch July 30, 2026 03:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant