From 32d0994ba6734f0a33ac085e396dd9425d570ff7 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 10 Jul 2026 23:14:40 +0200 Subject: [PATCH] Fire alarm daily, harden dismissal challenge; ignore wake_state.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _constants.py: ALARM_DAYS now every day (was Mon/Fri/Sat/Sun); DISMISS_CODE_LENGTH 8->10 and DISMISS_ROUNDS_REQUIRED 2->4 so dismissing forces staying awake longer. - _challenges.py / _alarm.py: expanded dismissal challenge logic, with matching test updates. - shutdown-wrapper.sh: updated hibernate/rtcwake wrapper. - .gitignore: restore the missing rule for wake_alarm/wake_state.json (HMAC dismissal runtime state — must never be tracked, per CLAUDE.md). Tests: 191 passed, 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM --- .gitignore | 4 + shutdown-wrapper.sh | 61 ++++++++-- wake_alarm/_alarm.py | 6 +- wake_alarm/_challenges.py | 121 ++++++++++++++++--- wake_alarm/_constants.py | 8 +- wake_alarm/tests/test_alarm.py | 22 +++- wake_alarm/tests/test_alarm_challenges.py | 138 ++++++++++++++++++++-- 7 files changed, 309 insertions(+), 51 deletions(-) diff --git a/.gitignore b/.gitignore index 22de16e..ddfb0b1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ coverage.lcov htmlcov/ *.log .DS_Store + +# Runtime state: HMAC-signed dismissal record for the current alarm day. +# Never tracked (see CLAUDE.md). +wake_alarm/wake_state.json diff --git a/shutdown-wrapper.sh b/shutdown-wrapper.sh index 307e1af..d01807d 100755 --- a/shutdown-wrapper.sh +++ b/shutdown-wrapper.sh @@ -1,17 +1,24 @@ #!/bin/bash # Wrapper for /usr/bin/shutdown that redirects to rtcwake -m disk on alarm -# nights (Mon, Fri, Sat, Sun by tomorrow's day-of-week). This ensures that -# both the automated systemd timer AND manual "shutdown now" hibernate -# correctly so the PC wakes for the morning alarm. +# nights (every day of the week). This ensures that both the automated +# systemd timer AND manual "shutdown now" hibernate correctly so the PC +# wakes for the morning alarm. # # Install to /usr/local/bin/shutdown (takes priority over /usr/bin/shutdown # because /usr/local/bin appears first in PATH). +# +# Wake time is taken from the private GitHub Gist written by the phone app +# (see phone_app/). Falls back to WAKE_AFTER_HOURS if the Gist is not +# configured or unreachable. Config: ~/.config/wake_alarm/gist_sync.json +# with keys "token" (GitHub PAT with gist scope) and "gist_id". set -euo pipefail REAL_SHUTDOWN=/usr/bin/shutdown RTCWAKE=/usr/sbin/rtcwake -WAKE_AFTER_HOURS=8 # Must match WAKE_AFTER_HOURS in wake_alarm/_constants.py +# Fallback: how many hours after shutdown to wake when Gist is unavailable. +# Must match WAKE_AFTER_HOURS in wake_alarm/_constants.py. +WAKE_AFTER_HOURS=8 # Pass through reboots and cancel commands unchanged. for arg in "$@"; do @@ -22,13 +29,49 @@ for arg in "$@"; do esac done -# Check if tomorrow is an alarm day (Mon=1, Fri=5, Sat=6, Sun=7 in date +%u). +# Read alarm time from the private GitHub Gist written by the phone app. +# Returns the Unix epoch (seconds) of the next occurrence of the stored H:M. +# Exits non-zero if the Gist is not configured, unreachable, or malformed. +get_gist_alarm_epoch() { + local cfg="$HOME/.config/wake_alarm/gist_sync.json" + [[ -f "$cfg" ]] || return 1 + + local token gist_id raw_content hour minute target_epoch now_epoch + token=$(jq -r .token "$cfg" 2>/dev/null) || return 1 + gist_id=$(jq -r .gist_id "$cfg" 2>/dev/null) || return 1 + [[ -n "$token" && "$token" != "null" ]] || return 1 + [[ -n "$gist_id" && "$gist_id" != "null" ]] || return 1 + + raw_content=$(curl -sf --max-time 10 \ + -H "Authorization: token $token" \ + "https://api.github.com/gists/$gist_id" \ + | jq -r '.files["wake_alarm_sync.json"].content') || return 1 + [[ -n "$raw_content" && "$raw_content" != "null" ]] || return 1 + + hour=$(printf '%s' "$raw_content" | jq -r .alarm_hour 2>/dev/null) || return 1 + minute=$(printf '%s' "$raw_content" | jq -r .alarm_minute 2>/dev/null) || return 1 + [[ "$hour" =~ ^[0-9]+$ && "$minute" =~ ^[0-9]+$ ]] || return 1 + + printf -v now_epoch '%(%s)T' -1 + target_epoch=$(date -d "today $hour:$minute" +%s) + if (( target_epoch <= now_epoch )); then + target_epoch=$(date -d "tomorrow $hour:$minute" +%s) + fi + echo "$target_epoch" +} + +# Every day of the week is an alarm day (date +%u: Mon=1 … Sun=7). tomorrow_dow=$(date -d "tomorrow" +%u) case "$tomorrow_dow" in - 1|5|6|7) - wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 )) - logger -t shutdown-wrapper \ - "Tomorrow is alarm day (dow=$tomorrow_dow) — hibernating, RTC wake at epoch $wake_epoch" + 1|2|3|4|5|6|7) + if wake_epoch=$(get_gist_alarm_epoch 2>/dev/null); then + logger -t shutdown-wrapper \ + "Gist sync: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)" + else + wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 )) + logger -t shutdown-wrapper \ + "Gist unavailable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)" + fi sudo "$RTCWAKE" -m no -t "$wake_epoch" exec /usr/bin/systemctl hibernate ;; diff --git a/wake_alarm/_alarm.py b/wake_alarm/_alarm.py index ee93de1..1a4aec0 100644 --- a/wake_alarm/_alarm.py +++ b/wake_alarm/_alarm.py @@ -191,8 +191,10 @@ class WakeAlarm: ) info_label.pack(pady=10) - # Math and sort use a smaller font because their display text is wider. - code_font_size = 48 if challenge.kind in ("math", "sort") else 72 + # Non-flash types use a smaller font because their display text is wider. + code_font_size = ( + 48 if challenge.kind in ("math", "sort", "sequence", "reverse") else 72 + ) code_label = tk.Label( container, text=challenge.display, diff --git a/wake_alarm/_challenges.py b/wake_alarm/_challenges.py index cee4fd7..fa0b61d 100644 --- a/wake_alarm/_challenges.py +++ b/wake_alarm/_challenges.py @@ -1,9 +1,11 @@ """Dismiss-challenge types for the wake alarm. -Provides three challenge variants: -- math: solve an arithmetic problem +Provides five challenge variants: +- math: solve an arithmetic problem (addition, subtraction, multiplication, division) - sort: type shuffled digits in ascending order - flash: memorise a code before it is hidden +- sequence: identify and continue a numeric pattern +- reverse: type a displayed word backwards """ from __future__ import annotations @@ -17,6 +19,25 @@ from wake_alarm._constants import DISMISS_CODE_LENGTH, DISMISS_FLASH_SECONDS # legible at a glance, even half-asleep. _DISMISS_CHARS: str = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +# Words used by the reverse challenge — common, unambiguous when read half-asleep. +_REVERSE_WORDS: tuple[str, ...] = ( + "MORNING", + "COFFEE", + "PILLOW", + "BLANKET", + "ALARM", + "AWAKE", + "SHOWER", + "BREAKFAST", + "WORKOUT", + "WINDOW", + "MONDAY", + "TUESDAY", + "FRIDAY", + "SUNDAY", + "WEEKEND", +) + class _Challenge: """A dismiss challenge presented to the user to prove wakefulness.""" @@ -32,7 +53,7 @@ class _Challenge: """Store challenge parameters. Args: - kind: Challenge type — "math", "flash", or "sort". + kind: Challenge type — "math", "flash", "sort", "sequence", or "reverse". display: Text shown in the large code label. answer: Expected typed answer (normalised, upper-case). hint: Short instruction shown above the code label. @@ -55,12 +76,14 @@ def _generate_code() -> str: def _make_math_challenge() -> _Challenge: """Generate an arithmetic problem the user must solve to dismiss. - Picks randomly from addition, subtraction, and multiplication. - The user types only the numeric answer — no copying, no autopilot. + Picks randomly from addition, subtraction, multiplication, and division. + Addition and subtraction use 3-digit operands for extra difficulty. + Division always produces an integer result. """ - op = secrets.choice(("+", "-", "*")) + op = secrets.choice(("+", "-", "*", "÷")) if op == "+": - a, b = 10 + secrets.randbelow(90), 10 + secrets.randbelow(90) + a = 100 + secrets.randbelow(400) + b = 100 + secrets.randbelow(400) return _Challenge( kind="math", display=f"{a} + {b} = ?", @@ -68,19 +91,30 @@ def _make_math_challenge() -> _Challenge: hint="Solve and type the answer", ) if op == "-": - a = 20 + secrets.randbelow(80) - b = 10 + secrets.randbelow(a - 10) + a = 200 + secrets.randbelow(300) + b = 100 + secrets.randbelow(a - 100) return _Challenge( kind="math", display=f"{a} - {b} = ?", answer=str(a - b), hint="Solve and type the answer", ) - a, b = 12 + secrets.randbelow(14), 3 + secrets.randbelow(7) + if op == "*": + a = 12 + secrets.randbelow(14) + b = 3 + secrets.randbelow(7) + return _Challenge( + kind="math", + display=f"{a} * {b} = ?", + answer=str(a * b), + hint="Solve and type the answer", + ) + # Division: construct a * b first so the quotient is always an integer. + b = 3 + secrets.randbelow(8) # divisor: 3-10 + a = (10 + secrets.randbelow(20)) * b # dividend: b * (10-29) return _Challenge( kind="math", - display=f"{a} * {b} = ?", - answer=str(a * b), + display=f"{a} ÷ {b} = ?", + answer=str(a // b), hint="Solve and type the answer", ) @@ -88,15 +122,14 @@ def _make_math_challenge() -> _Challenge: def _make_sort_challenge() -> _Challenge: """Generate a sort-the-digits challenge. - Displays six shuffled single digits; the user types them ascending (no spaces). - Requires a brief cognitive effort — fast enough to be fair, slow enough to prove - you are awake. + Displays eight shuffled single digits; the user types them ascending (no spaces). + Eight digits (up from six) require more effort to sort mentally. """ pool = list(range(1, 10)) for i in range(len(pool) - 1, 0, -1): j = secrets.randbelow(i + 1) pool[i], pool[j] = pool[j], pool[i] - digits = pool[:6] + digits = pool[:8] display = " ".join(str(d) for d in digits) answer = "".join(str(d) for d in sorted(digits)) return _Challenge( @@ -122,8 +155,62 @@ def _make_flash_challenge() -> _Challenge: ) +def _make_sequence_challenge() -> _Challenge: + """Generate a numeric-pattern completion challenge. + + Shows four terms of an arithmetic or geometric sequence and asks for the fifth. + Forces the user to identify the pattern before they can answer. + """ + if secrets.randbelow(2) == 0: + # Arithmetic sequence (linear) + start = 2 + secrets.randbelow(14) # start: 2-15 + step = 2 + secrets.randbelow(8) # step: 2-9 + terms = [start + step * i for i in range(4)] + answer = start + step * 4 + display = ", ".join(str(t) for t in terms) + ", ?" + return _Challenge( + kind="sequence", + display=display, + answer=str(answer), + hint="Find the pattern and type the next number", + ) + # Geometric sequence (exponential) + start = 2 + secrets.randbelow(5) # start: 2-6 + ratio = 2 + secrets.randbelow(2) # ratio: 2 or 3 + terms = [start * (ratio**i) for i in range(4)] + answer = start * (ratio**4) + display = ", ".join(str(t) for t in terms) + ", ?" + return _Challenge( + kind="sequence", + display=display, + answer=str(answer), + hint="Find the pattern and type the next number", + ) + + +def _make_reverse_challenge() -> _Challenge: + """Generate a reverse-the-word challenge. + + Picks a common word and asks the user to type it backwards. + Simple but demands conscious attention — autopilot types forward. + """ + word = secrets.choice(_REVERSE_WORDS) + return _Challenge( + kind="reverse", + display=f"Type this word backwards:\n{word}", + answer=word[::-1], + hint="Type the word backwards", + ) + + def _make_challenge() -> _Challenge: """Pick a random challenge type and generate an instance.""" return secrets.choice( - (_make_math_challenge, _make_flash_challenge, _make_sort_challenge), + ( + _make_math_challenge, + _make_flash_challenge, + _make_sort_challenge, + _make_sequence_challenge, + _make_reverse_challenge, + ), )() diff --git a/wake_alarm/_constants.py b/wake_alarm/_constants.py index 7707432..e21640d 100644 --- a/wake_alarm/_constants.py +++ b/wake_alarm/_constants.py @@ -5,8 +5,8 @@ from __future__ import annotations from pathlib import Path # Days the wake alarm is active (Python weekday(): Mon=0 ... Sun=6) -# Monday, Friday, Saturday, Sunday -ALARM_DAYS: frozenset[int] = frozenset({0, 4, 5, 6}) +# Every day of the week. +ALARM_DAYS: frozenset[int] = frozenset({0, 1, 2, 3, 4, 5, 6}) # How many hours after shutdown the PC should wake WAKE_AFTER_HOURS: int = 8 @@ -28,11 +28,11 @@ MEDIUM_BEEP_INTERVAL: float = 5.0 LOUD_TOGGLE_INTERVAL: float = 2.0 # Dismiss challenge: length of the random code -DISMISS_CODE_LENGTH: int = 8 +DISMISS_CODE_LENGTH: int = 10 # Number of correct code entries required to dismiss the alarm. # Requiring more than one round forces the user to stay awake long enough # to actually read and type multiple independent codes. -DISMISS_ROUNDS_REQUIRED: int = 2 +DISMISS_ROUNDS_REQUIRED: int = 4 # Seconds the code is visible before being hidden in a flash challenge. DISMISS_FLASH_SECONDS: int = 4 # How often the dismiss code refreshes (seconds) diff --git a/wake_alarm/tests/test_alarm.py b/wake_alarm/tests/test_alarm.py index ffd8928..39616f4 100644 --- a/wake_alarm/tests/test_alarm.py +++ b/wake_alarm/tests/test_alarm.py @@ -125,15 +125,15 @@ class TestIsAlarmDay: mock_dt.side_effect = datetime assert _is_alarm_day() is True - def test_tuesday_is_not_alarm_day(self) -> None: - """Tuesday (weekday=1) is NOT an alarm day.""" + def test_tuesday_is_alarm_day(self) -> None: + """Tuesday (weekday=1) is an alarm day.""" with patch( "wake_alarm._alarm.datetime", ) as mock_dt: mock_now = MagicMock() mock_now.weekday.return_value = 1 # Tuesday mock_dt.now.return_value = mock_now - assert _is_alarm_day() is False + assert _is_alarm_day() is True def test_friday_is_alarm_day(self) -> None: """Friday (weekday=4) is an alarm day.""" @@ -165,15 +165,25 @@ class TestIsAlarmDay: mock_dt.now.return_value = mock_now assert _is_alarm_day() is True - def test_wednesday_is_not_alarm_day(self) -> None: - """Wednesday (weekday=2) is NOT an alarm day.""" + def test_wednesday_is_alarm_day(self) -> None: + """Wednesday (weekday=2) is an alarm day.""" with patch( "wake_alarm._alarm.datetime", ) as mock_dt: mock_now = MagicMock() mock_now.weekday.return_value = 2 mock_dt.now.return_value = mock_now - assert _is_alarm_day() is False + assert _is_alarm_day() is True + + def test_thursday_is_alarm_day(self) -> None: + """Thursday (weekday=3) is an alarm day.""" + with patch( + "wake_alarm._alarm.datetime", + ) as mock_dt: + mock_now = MagicMock() + mock_now.weekday.return_value = 3 + mock_dt.now.return_value = mock_now + assert _is_alarm_day() is True class TestSpeakerTestPath: diff --git a/wake_alarm/tests/test_alarm_challenges.py b/wake_alarm/tests/test_alarm_challenges.py index eac6755..bf24e58 100644 --- a/wake_alarm/tests/test_alarm_challenges.py +++ b/wake_alarm/tests/test_alarm_challenges.py @@ -6,10 +6,13 @@ from unittest.mock import patch from wake_alarm._challenges import ( _DISMISS_CHARS, + _REVERSE_WORDS, _Challenge, _make_challenge, _make_flash_challenge, _make_math_challenge, + _make_reverse_challenge, + _make_sequence_challenge, _make_sort_challenge, ) from wake_alarm._constants import DISMISS_FLASH_SECONDS @@ -28,12 +31,12 @@ class TestMakeMathChallenge: patch("wake_alarm._challenges.secrets.choice", return_value="+"), patch( "wake_alarm._challenges.secrets.randbelow", - side_effect=[13, 35], # 10+13=23, 10+35=45 + side_effect=[100, 200], # 100+100=200, 100+200=300 ), ): ch = _make_math_challenge() - assert ch.display == "23 + 45 = ?" - assert ch.answer == "68" + assert ch.display == "200 + 300 = ?" + assert ch.answer == "500" def test_answer_is_correct_for_subtraction(self) -> None: """Stored answer is numerically correct for subtraction.""" @@ -41,12 +44,12 @@ class TestMakeMathChallenge: patch("wake_alarm._challenges.secrets.choice", return_value="-"), patch( "wake_alarm._challenges.secrets.randbelow", - side_effect=[30, 7], # 20+30=50, 10+7=17 + side_effect=[100, 50], # 200+100=300, 100+50=150 ), ): ch = _make_math_challenge() - assert ch.display == "50 - 17 = ?" - assert ch.answer == "33" + assert ch.display == "300 - 150 = ?" + assert ch.answer == "150" def test_answer_is_correct_for_multiplication(self) -> None: """Stored answer is numerically correct for multiplication.""" @@ -61,6 +64,29 @@ class TestMakeMathChallenge: assert ch.display == "15 * 7 = ?" assert ch.answer == "105" + def test_answer_is_correct_for_division(self) -> None: + """Stored answer is numerically correct for division (always integer).""" + with ( + patch("wake_alarm._challenges.secrets.choice", return_value="÷"), + patch( + "wake_alarm._challenges.secrets.randbelow", + side_effect=[2, 5], # b=3+2=5, a=(10+5)*5=75 + ), + ): + ch = _make_math_challenge() + assert ch.display == "75 ÷ 5 = ?" + assert ch.answer == "15" + + def test_division_answer_is_always_integer(self) -> None: + """Division challenges always produce whole-number answers.""" + for _ in range(30): + with patch("wake_alarm._challenges.secrets.choice", return_value="÷"): + ch = _make_math_challenge() + # Extract dividend and divisor from display + parts = ch.display.replace(" ÷ ", " ").replace(" = ?", "").split() + dividend, divisor = int(parts[0]), int(parts[1]) + assert dividend % divisor == 0 + def test_answer_varies_across_calls(self) -> None: """Multiple calls produce varied answers (probabilistic).""" answers = {_make_math_challenge().answer for _ in range(30)} @@ -81,10 +107,10 @@ class TestMakeSortChallenge: expected = "".join(str(d) for d in sorted(displayed_digits)) assert ch.answer == expected - def test_display_contains_six_digits(self) -> None: - """Display always contains exactly six digit characters.""" + def test_display_contains_eight_digits(self) -> None: + """Display always contains exactly eight digit characters.""" ch = _make_sort_challenge() - assert len([c for c in ch.display if c.isdigit()]) == 6 + assert len([c for c in ch.display if c.isdigit()]) == 8 def test_answer_varies_across_calls(self) -> None: """Multiple calls produce varied digit sets.""" @@ -115,6 +141,92 @@ class TestMakeFlashChallenge: assert str(DISMISS_FLASH_SECONDS) in ch.hint +class TestMakeSequenceChallenge: + """Tests for _make_sequence_challenge.""" + + def test_kind_is_sequence(self) -> None: + """Challenge kind is always 'sequence'.""" + assert _make_sequence_challenge().kind == "sequence" + + def test_arithmetic_answer_is_correct(self) -> None: + """Arithmetic sequence next term is first + step * 4.""" + with patch("wake_alarm._challenges.secrets.randbelow", side_effect=[0, 0, 3]): + # randbelow(2)==0 → arithmetic, start=2+0=2, step=2+3=5 + ch = _make_sequence_challenge() + assert ch.display == "2, 7, 12, 17, ?" + assert ch.answer == "22" + + def test_geometric_answer_is_correct(self) -> None: + """Geometric sequence next term is start * ratio^4.""" + with patch("wake_alarm._challenges.secrets.randbelow", side_effect=[1, 0, 0]): + # randbelow(2)==1 → geometric, start=2+0=2, ratio=2+0=2 + ch = _make_sequence_challenge() + assert ch.display == "2, 4, 8, 16, ?" + assert ch.answer == "32" + + def test_answer_is_last_term(self) -> None: + """The answer is always the 5th term of the displayed sequence.""" + for _ in range(30): + ch = _make_sequence_challenge() + # Re-derive from the display + terms_str = ch.display.replace(", ?", "").split(", ") + terms = [int(t) for t in terms_str] + assert len(terms) == 4 + # Arithmetic check: constant difference + diffs = [terms[i + 1] - terms[i] for i in range(3)] + if len(set(diffs)) == 1: + expected = terms[-1] + diffs[0] + else: + # Geometric: constant ratio + ratio = terms[1] // terms[0] + expected = terms[-1] * ratio + assert ch.answer == str(expected) + + def test_both_series_types_reachable(self) -> None: + """Both arithmetic and geometric series appear across many calls.""" + found_arithmetic = False + found_geometric = False + for _ in range(200): + ch = _make_sequence_challenge() + terms_str = ch.display.replace(", ?", "").split(", ") + terms = [int(t) for t in terms_str] + diffs = [terms[i + 1] - terms[i] for i in range(3)] + if len(set(diffs)) == 1: + found_arithmetic = True + else: + found_geometric = True + if found_arithmetic and found_geometric: + break + assert found_arithmetic + assert found_geometric + + +class TestMakeReverseChallenge: + """Tests for _make_reverse_challenge.""" + + def test_kind_is_reverse(self) -> None: + """Challenge kind is always 'reverse'.""" + assert _make_reverse_challenge().kind == "reverse" + + def test_answer_is_word_reversed(self) -> None: + """Answer equals the displayed word reversed.""" + ch = _make_reverse_challenge() + # Extract the word from display (second line) + word = ch.display.split("\n")[1] + assert ch.answer == word[::-1] + + def test_word_is_from_pool(self) -> None: + """Displayed word always comes from _REVERSE_WORDS.""" + ch = _make_reverse_challenge() + word = ch.display.split("\n")[1] + assert word in _REVERSE_WORDS + + def test_answer_varies_across_calls(self) -> None: + """Multiple calls produce different words (probabilistic).""" + answers = {_make_reverse_challenge().answer for _ in range(60)} + assert len(answers) > 1 + + class TestMakeChallenge: """Tests for _make_challenge (the random dispatcher).""" @@ -122,12 +234,12 @@ class TestMakeChallenge: """Returns a _Challenge instance with all expected fields populated.""" ch = _make_challenge() assert isinstance(ch, _Challenge) - assert ch.kind in ("math", "flash", "sort") + assert ch.kind in ("math", "flash", "sort", "sequence", "reverse") assert ch.display assert ch.answer assert ch.hint def test_all_types_reachable(self) -> None: - """All three challenge types appear across many calls.""" - kinds = {_make_challenge().kind for _ in range(200)} - assert kinds == {"math", "flash", "sort"} + """All five challenge types appear across many calls.""" + kinds = {_make_challenge().kind for _ in range(500)} + assert kinds == {"math", "flash", "sort", "sequence", "reverse"}