refactor: split bot.py into runtime/dispatch/worker modules; add retry scheduler, route TTL, ack tracking, follow-up screener, structured event log, and test suite

This commit is contained in:
swaits committed 2026-04-16 13:47:17 -06:00
1 parent 9e6f171c5d
commit 228432f9e3
24 files changed
+3531 -434

No files matched your search

View File
Whitespace-only changes.
+11
View File
@@ -0,0 +1,11 @@
"""Shared pytest fixtures and path setup."""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure the project root is on sys.path so `import orac...` works from anywhere.
_ROOT = Path(__file__).resolve().parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
+42
View File
@@ -0,0 +1,42 @@
"""ACK-hash stability across retry rebuilds.
The retry scheduler must be able to recompute the expected ACK CRC using the
same plaintext (ts + attempt byte + text) that the receiver will compute against.
"""
from __future__ import annotations
from orac.crypto import build_peer_plaintext
from orac.meshcore import compute_ack_hash
def test_ack_hash_stable_for_same_inputs() -> None:
ts = 1700000000
pt1 = build_peer_plaintext("hello", ts=ts, attempt=0)
pt2 = build_peer_plaintext("hello", ts=ts, attempt=0)
sender = b"\x11" * 32
assert compute_ack_hash(pt1, sender) == compute_ack_hash(pt2, sender)
def test_ack_hash_changes_with_attempt_byte() -> None:
ts = 1700000000
pt0 = build_peer_plaintext("hello", ts=ts, attempt=0)
pt1 = build_peer_plaintext("hello", ts=ts, attempt=1)
sender = b"\x22" * 32
assert compute_ack_hash(pt0, sender) != compute_ack_hash(pt1, sender)
def test_ack_hash_changes_with_text() -> None:
ts = 1700000000
sender = b"\x33" * 32
a = compute_ack_hash(build_peer_plaintext("hello", ts=ts, attempt=0), sender)
b = compute_ack_hash(build_peer_plaintext("world", ts=ts, attempt=0), sender)
assert a != b
def test_ack_hash_changes_with_sender() -> None:
ts = 1700000000
pt = build_peer_plaintext("hello", ts=ts, attempt=0)
a = compute_ack_hash(pt, b"\x44" * 32)
b = compute_ack_hash(pt, b"\x55" * 32)
assert a != b
+46
View File
@@ -0,0 +1,46 @@
"""Tests for orac.followup: recent-activity tracker + screener rate limit."""
from __future__ import annotations
import time
from orac import followup
def test_untouched_channel_is_not_recent() -> None:
followup.reset()
assert followup.was_recent("unused", window_s=60.0) is False
assert followup.last_interaction_age("unused") is None
def test_touch_then_was_recent_within_window() -> None:
followup.reset()
followup.touch("Public")
assert followup.was_recent("Public", window_s=60.0) is True
age = followup.last_interaction_age("Public")
assert age is not None and age < 1.0
def test_was_recent_expires_after_window() -> None:
followup.reset()
followup.touch("Public")
# Immediately check with a zero-width window — should not count as recent.
time.sleep(0.01)
assert followup.was_recent("Public", window_s=0.001) is False
def test_screener_ok_rate_limits_per_channel() -> None:
followup.reset()
# First call allowed
assert followup.screener_ok("Public", min_interval_s=0.5) is True
# Immediate second call blocked
assert followup.screener_ok("Public", min_interval_s=0.5) is False
# Different channel not blocked
assert followup.screener_ok("#general", min_interval_s=0.5) is True
def test_screener_ok_allows_after_interval() -> None:
followup.reset()
assert followup.screener_ok("Public", min_interval_s=0.05) is True
time.sleep(0.1)
assert followup.screener_ok("Public", min_interval_s=0.05) is True
+102
View File
@@ -0,0 +1,102 @@
"""Protocol fidelity: MULTIPART ACK packet layout + RX unwrap.
Mirrors upstream MeshCore:
- `createMultiAck(crc, remaining)` (src/Mesh.cpp:570-583)
- `createAck(crc)` (src/Mesh.cpp:556-568)
- Receiver unwrap (src/Mesh.cpp:270-282)
"""
from __future__ import annotations
from orac import meshcore
from orac.meshcore import build_ack_packet, build_multiack_packet, parse_header_and_path
def _clear_routes() -> None:
with meshcore._route_lock:
meshcore._route_table.clear()
def test_multiack_flood_layout() -> None:
"""With no learned route, MULTIPART uses flood route_type = 1."""
_clear_routes()
crc = bytes([0xDE, 0xAD, 0xBE, 0xEF])
packet = build_multiack_packet(crc, remaining=1, dest_hash=0x42)
# Expect: header(1) + path_len(1) + payload(5)
# header: PAYLOAD_TYPE_MULTIPART(0x0A) << 2 | ROUTE_TYPE_FLOOD(0x01)
# = (0x0A << 2) | 0x01 = 0x29
assert packet[0] == 0x29, f"header byte wrong: {packet[0]:#04x}"
# path_len byte: 0x40 = hash_size_code=1 (2-byte), 0 hops
assert packet[1] == 0x40
# Payload: (remaining << 4) | 0x03 + crc
assert packet[2] == (1 << 4) | 0x03
assert packet[3:7] == crc
def test_multiack_direct_layout_with_route() -> None:
"""With a learned direct route, header uses ROUTE_TYPE_DIRECT = 2."""
_clear_routes()
# Seed a 2-byte single-hop route
meshcore.learn_route(0x77, [b"\xaa\xbb"], hash_size=2, snr=10.0)
crc = bytes([0x01, 0x02, 0x03, 0x04])
packet = build_multiack_packet(crc, remaining=2, dest_hash=0x77)
# header = (0x0A << 2) | 0x02 (ROUTE_TYPE_DIRECT) = 0x2A
assert packet[0] == 0x2A, f"direct-route header wrong: {packet[0]:#04x}"
# path_len byte: hash_size_code=1 (2-byte), 1 hop -> 0x41
assert packet[1] == 0x41
# Path data
assert packet[2:4] == b"\xaa\xbb"
# Payload: (remaining << 4) | 0x03 + crc
assert packet[4] == (2 << 4) | 0x03
assert packet[5:9] == crc
def test_multiack_remaining_nibble_bounds() -> None:
import pytest
with pytest.raises(ValueError):
build_multiack_packet(b"\x00\x00\x00\x00", remaining=-1, dest_hash=0x11)
with pytest.raises(ValueError):
build_multiack_packet(b"\x00\x00\x00\x00", remaining=16, dest_hash=0x11)
# 0 and 15 are valid
build_multiack_packet(b"\x00\x00\x00\x00", remaining=0, dest_hash=0x11)
build_multiack_packet(b"\x00\x00\x00\x00", remaining=15, dest_hash=0x11)
def test_multiack_crc_length_enforced() -> None:
import pytest
with pytest.raises(ValueError):
build_multiack_packet(b"\x00\x00\x00", remaining=1, dest_hash=0x11)
with pytest.raises(ValueError):
build_multiack_packet(b"\x00\x00\x00\x00\x00", remaining=1, dest_hash=0x11)
def test_plain_ack_layout_unchanged() -> None:
"""Regression: plain PAYLOAD_TYPE_ACK (0x03) still works as before."""
_clear_routes()
crc = bytes([0xAA, 0xBB, 0xCC, 0xDD])
packet = build_ack_packet(crc, dest_hash=0x99)
# header = (0x03 << 2) | 0x01 = 0x0D (flood)
assert packet[0] == 0x0D
assert packet[1] == 0x40
# Payload is just the 4-byte CRC, no remaining-nibble byte.
assert packet[2:6] == crc
assert len(packet) == 6
def test_round_trip_parse_extracts_multiack_payload() -> None:
"""Firmware-style RX: parse_header_and_path strips header+path and hands us
the 5-byte MULTIPART payload; our handler expects [nibble|type][crc:4]."""
_clear_routes()
crc = bytes([0xC0, 0xDE, 0xCA, 0xFE])
packet = build_multiack_packet(crc, remaining=1, dest_hash=0x33)
parsed = parse_header_and_path(packet)
assert parsed is not None
payload_type, route_type, _ver, _pos, payload, _path_hops, _hash_size = parsed
assert payload_type == 0x0A # PAYLOAD_TYPE_MULTIPART
assert route_type == 0x01 # flood (no route learned)
assert len(payload) == 5
assert payload[0] & 0x0F == 0x03 # inner type == PAYLOAD_TYPE_ACK
assert payload[0] >> 4 == 1 # remaining = 1
assert payload[1:5] == crc
+129
View File
@@ -0,0 +1,129 @@
"""Tests for orac.reply_state.PendingAckTable and ReplyCache."""
from __future__ import annotations
import time
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache
def _entry(ack: bytes, peer_pk: bytes, attempt: int = 1) -> PendingReply:
now = time.monotonic()
return PendingReply(
expected_ack=ack,
peer_pk=peer_pk,
peer_name=f"peer{peer_pk[0]:02x}",
text="hello",
first_ts=1234567890,
first_sent_at=now,
attempt=attempt,
max_attempts=5,
retry_at=now + 6.0,
)
def test_register_and_consume_roundtrip() -> None:
table = PendingAckTable(cap=4)
ack = b"\x01\x02\x03\x04"
table.register(_entry(ack, b"\xaa" * 32))
assert table.depth() == 1
entry = table.consume(ack)
assert entry is not None
assert entry.expected_ack == ack
assert table.depth() == 0
# Consuming again returns None.
assert table.consume(ack) is None
def test_expired_returns_entries_past_retry_at() -> None:
table = PendingAckTable()
ack = b"\x00\x00\x00\x01"
e = _entry(ack, b"\x11" * 32)
table.register(e)
# retry_at was set to now + 6.0 — force expiry by passing a far-future "now".
expired = table.expired(time.monotonic() + 10.0)
assert len(expired) == 1
assert expired[0].expected_ack == ack
# expired() does not remove.
assert table.depth() == 1
def test_replace_key_rekeys_entry() -> None:
table = PendingAckTable()
old = b"\x00\x00\x00\x01"
new = b"\x00\x00\x00\x02"
table.register(_entry(old, b"\x22" * 32))
table.replace_key(old, new)
assert table.consume(old) is None
got = table.consume(new)
assert got is not None and got.expected_ack == new
def test_has_pending_for_peer() -> None:
table = PendingAckTable()
pk_a = b"\x10" + b"\x00" * 31
pk_b = b"\x20" + b"\x00" * 31
table.register(_entry(b"\x00\x00\x00\x01", pk_a))
assert table.has_pending_for_peer(pk_a) is True
assert table.has_pending_for_peer(pk_b) is False
def test_cap_evicts_oldest_on_overflow() -> None:
table = PendingAckTable(cap=2)
table.register(_entry(b"\x01\x00\x00\x00", b"\xaa" * 32))
table.register(_entry(b"\x02\x00\x00\x00", b"\xbb" * 32))
table.register(_entry(b"\x03\x00\x00\x00", b"\xcc" * 32))
# Oldest (first registered) should be gone.
assert table.consume(b"\x01\x00\x00\x00") is None
assert table.consume(b"\x02\x00\x00\x00") is not None
assert table.consume(b"\x03\x00\x00\x00") is not None
def test_reply_cache_roundtrip() -> None:
cache = ReplyCache(cap=4, ttl=100.0)
pk = b"\x55" * 32
cache.put(
peer_pk=pk,
peer_name="alice",
dm_text="hi",
reply_packet=b"\xff\xee",
reply_text="hello back",
expected_ack=b"\x01\x02\x03\x04",
)
got = cache.get(pk.hex(), "hi")
assert got is not None and got.reply_text == "hello back"
# Different DM text misses.
assert cache.get(pk.hex(), "different") is None
def test_reply_cache_ttl_expires() -> None:
cache = ReplyCache(cap=4, ttl=0.001)
pk = b"\x66" * 32
cache.put(
peer_pk=pk,
peer_name="bob",
dm_text="q",
reply_packet=b"\x00",
reply_text="a",
expected_ack=b"\x00\x00\x00\x00",
)
time.sleep(0.01)
assert cache.get(pk.hex(), "q") is None
def test_reply_cache_ts_lets_caller_gate_on_age() -> None:
"""Caller can decide whether a hit is within the loss-recovery window."""
cache = ReplyCache(cap=4, ttl=100.0)
pk = b"\x77" * 32
cache.put(
peer_pk=pk,
peer_name="carol",
dm_text="status?",
reply_packet=b"\x00",
reply_text="ok",
expected_ack=b"\x00\x00\x00\x01",
)
got = cache.get(pk.hex(), "status?")
assert got is not None
# Caller interprets .ts; entry is fresh right now.
assert (time.monotonic() - got.ts) < 1.0
+149
View File
@@ -0,0 +1,149 @@
"""Tests for orac.runtime.RetryScheduler: attempt progression, path reset, exhaustion."""
from __future__ import annotations
import itertools
import time
from orac.constants import (
PENDING_ACK_TTL_S,
REPLY_FLOOD_FIRST_ATTEMPT,
REPLY_MAX_ATTEMPTS,
RETRY_SCHEDULE_S,
)
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache # noqa: F401
from orac.runtime import Metrics, RetryScheduler, TxQueue
def _entry(
ack: bytes,
attempt: int = 1,
retry_at_offset: float = 0.0,
max_attempts: int = 5,
) -> PendingReply:
now = time.monotonic()
return PendingReply(
expected_ack=ack,
peer_pk=b"\xaa" * 32,
peer_name="peer_aa",
text="hi",
first_ts=1234567890,
first_sent_at=now,
attempt=attempt,
max_attempts=max_attempts,
retry_at=now + retry_at_offset,
)
def _noop_rebuild(_entry: PendingReply, attempt: int, force_flood: bool) -> tuple[bytes, bytes]:
"""Rebuild stub: new ACK differs from old so replace_key is exercised."""
new_ack = bytes([attempt & 0xFF, 0xAA, 0xBB, 0xCC])
return (b"REBUILT-%d-%s" % (attempt, b"F" if force_flood else b"D"), new_ack)
def test_direct_retry_arms_next_attempt() -> None:
pending = PendingAckTable()
tx = TxQueue()
m = Metrics()
forgot: list[int] = []
sched = RetryScheduler(
pending=pending,
tx_queue=tx,
metrics=m,
rebuild_reply=_noop_rebuild,
forget_route_fn=lambda h: (forgot.append(h), True)[1],
)
# First attempt (entry.attempt=1) with retry_at already passed.
e = _entry(b"\x00\x00\x00\x01", attempt=1, retry_at_offset=-0.1)
pending.register(e)
sched.tick(time.monotonic())
# Next attempt (2) is a direct retry — route should NOT have been forgotten.
assert forgot == []
# The entry's expected_ack has been re-keyed.
assert pending.consume(b"\x00\x00\x00\x01") is None
# New key from _noop_rebuild is (attempt=2, ...).
item = tx.pop_ready(time.monotonic())
assert item is not None
assert item.attempt == 2
assert item.expected_ack == bytes([0x02, 0xAA, 0xBB, 0xCC])
def test_flood_retry_forgets_route() -> None:
pending = PendingAckTable()
tx = TxQueue()
m = Metrics()
forgot: list[int] = []
sched = RetryScheduler(
pending=pending,
tx_queue=tx,
metrics=m,
rebuild_reply=_noop_rebuild,
forget_route_fn=lambda h: (forgot.append(h), True)[1],
)
# Entry attempt = REPLY_FLOOD_FIRST_ATTEMPT - 1; next tick hits flood attempt.
e = _entry(
b"\x00\x00\x00\x02",
attempt=REPLY_FLOOD_FIRST_ATTEMPT - 1,
retry_at_offset=-0.1,
)
pending.register(e)
sched.tick(time.monotonic())
# route should have been forgotten for the peer's 1-byte hash.
assert forgot == [e.peer_pk[0]]
item = tx.pop_ready(time.monotonic())
assert item is not None
assert item.attempt == REPLY_FLOOD_FIRST_ATTEMPT
# The rebuilt packet's label tag for flood is 'F' in our stub.
assert item.packet.endswith(b"F")
def test_exhausted_drops_and_counts() -> None:
pending = PendingAckTable()
tx = TxQueue()
m = Metrics()
sched = RetryScheduler(
pending=pending,
tx_queue=tx,
metrics=m,
rebuild_reply=_noop_rebuild,
forget_route_fn=lambda _h: False,
)
e = _entry(b"\x00\x00\x00\x03", attempt=5, retry_at_offset=-0.1, max_attempts=5)
pending.register(e)
sched.tick(time.monotonic())
assert pending.depth() == 0
counters, _, _ = m.snapshot()
assert counters.get("reply_exhausted", 0) == 1
def test_retry_schedule_is_airtime_headroom() -> None:
# Guardrail: encourage authors to keep realistic spacing.
assert RETRY_SCHEDULE_S[0] >= 3.0, "attempt-2 must leave headroom for a 2-hop RTT"
for prev, cur in itertools.pairwise(RETRY_SCHEDULE_S):
assert cur >= prev, "retry schedule should be monotonically non-decreasing"
def test_schedule_covers_all_retries_for_max_attempts() -> None:
"""Each retry after the initial send needs an explicit delay entry."""
# REPLY_MAX_ATTEMPTS counts the initial send, so retries = MAX - 1.
assert len(RETRY_SCHEDULE_S) >= REPLY_MAX_ATTEMPTS - 1, (
"RETRY_SCHEDULE_S too short for REPLY_MAX_ATTEMPTS — the final retry clamps "
"back to the last entry rather than using a distinct delay"
)
def test_pending_ack_ttl_exceeds_final_retry_plus_headroom() -> None:
"""TTL sweep must not kill pending entries before the last retry's ACK window."""
total_retry_delay = sum(RETRY_SCHEDULE_S[: REPLY_MAX_ATTEMPTS - 1])
# At least 20 s past the final retry's send time for its ACK to arrive.
min_required = total_retry_delay + 20
assert min_required <= PENDING_ACK_TTL_S, (
f"PENDING_ACK_TTL_S={PENDING_ACK_TTL_S} cuts the last retry short; "
f"needs >= {min_required}s (sum of retry delays + 20s ACK window)"
)
+95
View File
@@ -0,0 +1,95 @@
"""Tests for orac.meshcore route table TTL and SNR upgrade rule."""
from __future__ import annotations
import time
from unittest.mock import patch
import orac.meshcore as mc
def _clear_route_table() -> None:
with mc._route_lock:
mc._route_table.clear()
def test_learn_and_get_roundtrip() -> None:
_clear_route_table()
mc.learn_route(0xAB, [b"\x11\x22"], hash_size=2, snr=10.0)
got = mc.get_route(0xAB)
assert got is not None
hops, hs = got
# path stored reversed
assert hops == [b"\x11\x22"]
assert hs == 2
def test_ttl_expiry_and_lazy_eviction() -> None:
_clear_route_table()
mc.learn_route(0xCD, [b"\x01\x02"], hash_size=2, snr=5.0)
# Fast-forward monotonic time by patching time.monotonic to return now+TTL+1
fake_future = time.monotonic() + mc.ROUTE_TTL_S + 1.0
with patch("orac.meshcore.time.monotonic", return_value=fake_future):
assert mc.get_route(0xCD) is None
# Entry should have been lazy-evicted.
assert mc.get_route(0xCD) is None
def test_forget_route_removes_entry() -> None:
_clear_route_table()
mc.learn_route(0x55, [b"\xaa\xbb"], hash_size=2)
assert mc.forget_route(0x55) is True
assert mc.get_route(0x55) is None
# Double-forget is a no-op.
assert mc.forget_route(0x55) is False
def test_snr_upgrade_rule_rejects_bad_path() -> None:
_clear_route_table()
mc.learn_route(0x77, [b"\x01\x02", b"\x03\x04"], hash_size=2, snr=10.0)
# New path arrives with much worse SNR → existing path kept.
mc.learn_route(0x77, [b"\xff\xff"], hash_size=2, snr=-5.0)
got = mc.get_route(0x77)
assert got is not None
hops, _ = got
assert hops == [b"\x03\x04", b"\x01\x02"] # original (reversed) hops
def test_snr_upgrade_rule_accepts_comparable_path() -> None:
_clear_route_table()
mc.learn_route(0x88, [b"\x01\x02", b"\x03\x04"], hash_size=2, snr=5.0)
# New path with slightly worse SNR (within 3 dB tolerance) → upgrade (refreshes learned_at).
mc.learn_route(0x88, [b"\xaa\xbb"], hash_size=2, snr=3.0)
got = mc.get_route(0x88)
assert got is not None
hops, _ = got
assert hops == [b"\xaa\xbb"]
def test_hash_size_downgrade_rejected() -> None:
_clear_route_table()
mc.learn_route(0x99, [b"\x01\x02"], hash_size=2, snr=10.0)
# Smaller hash size should NOT replace a larger one.
mc.learn_route(0x99, [b"\xff"], hash_size=1, snr=10.0)
got = mc.get_route(0x99)
assert got is not None
_, hs = got
assert hs == 2
def test_one_byte_hash_paths_are_never_learned() -> None:
"""Policy: we refuse to learn routes with 1-byte hashes (MIN_HASH_SIZE=2)."""
_clear_route_table()
mc.learn_route(0xAA, [b"\xff"], hash_size=1, snr=10.0)
assert mc.get_route(0xAA) is None
def test_build_path_return_rejects_one_byte_hashes() -> None:
"""Policy: refuse to echo 1-byte PATH returns."""
import pytest
peer_pk = b"\x55" * 32
with pytest.raises(ValueError):
mc.build_path_return_packet(peer_pk, [b"\xff"], hash_size=1)
+90
View File
@@ -0,0 +1,90 @@
"""Tests for orac.runtime.TxQueue: priority ordering, not_before gating, eviction."""
from __future__ import annotations
import time
from orac.runtime import TxItem, TxPriority, TxQueue
def _item(
priority: int,
not_before: float,
*,
label: str = "x",
expected_ack: bytes | None = None,
) -> TxItem:
return TxItem(
priority=priority,
not_before=not_before,
packet=b"\x00\x01\x02",
label=label,
expected_ack=expected_ack,
)
def test_pop_ready_none_when_nothing_due() -> None:
q = TxQueue(cap=8)
now = time.monotonic()
q.push(_item(TxPriority.ACK, now + 10.0))
assert q.pop_ready(now) is None
def test_pop_ready_highest_priority_wins_when_all_ready() -> None:
q = TxQueue(cap=8)
now = time.monotonic()
q.push(_item(TxPriority.ADVERT, now - 1.0, label="advert"))
q.push(_item(TxPriority.REPLY_FLOOD, now - 1.0, label="reply_flood"))
q.push(_item(TxPriority.ACK, now - 1.0, label="ack"))
q.push(_item(TxPriority.PATH, now - 1.0, label="path"))
order: list[str] = []
for _ in range(4):
item = q.pop_ready(now)
assert item is not None
order.append(item.label)
assert order == ["ack", "path", "reply_flood", "advert"]
def test_not_before_gates_lower_priority_first() -> None:
q = TxQueue(cap=8)
now = time.monotonic()
q.push(_item(TxPriority.ACK, now + 5.0, label="future_ack"))
q.push(_item(TxPriority.ADVERT, now - 1.0, label="advert"))
item = q.pop_ready(now)
assert item is not None
assert item.label == "advert"
# Future ACK still gated
assert q.pop_ready(now) is None
def test_eviction_drops_lowest_priority_oldest_when_cap() -> None:
q = TxQueue(cap=3)
now = time.monotonic()
q.push(_item(TxPriority.ACK, now, label="a"))
q.push(_item(TxPriority.PATH, now, label="b"))
q.push(_item(TxPriority.ADVERT, now, label="c"))
# Cap hit; new ACK should evict ADVERT.
q.push(_item(TxPriority.ACK, now, label="d"))
labels: set[str] = set()
for _ in range(3):
item = q.pop_ready(now)
assert item is not None
labels.add(item.label)
assert labels == {"a", "b", "d"}
def test_cancel_by_ack_removes_matching_items() -> None:
q = TxQueue(cap=8)
now = time.monotonic()
q.push(_item(TxPriority.REPLY_DIRECT, now, label="x", expected_ack=b"\xde\xad\xbe\xef"))
q.push(_item(TxPriority.REPLY_DIRECT, now, label="y", expected_ack=b"\x01\x02\x03\x04"))
q.push(_item(TxPriority.ACK, now, label="ack_bare"))
removed = q.cancel_by_ack(b"\xde\xad\xbe\xef")
assert removed == 1
# The other two items survive.
remaining: set[str] = set()
for _ in range(2):
item = q.pop_ready(now)
assert item is not None
remaining.add(item.label)
assert remaining == {"y", "ack_bare"}