Skip to content

Commit 198dcb4

Browse files
committed
RPN v2.0.0 — symbolic stack with 2D rendering
A Reverse Polish Notation calculator for NumWorks with a general symbolic expression engine. - Stack value = canonical polynomial-in-atoms expression tree (STL-free, fixed arena + compacting GC, overflow-checked int64, device-sized pools): exact fractions, k√m, rational multiples of π, sums/products/integer powers, nested radicals (√(1+√2)), conjugate division (1/(1+√2) → √2−1) and symbolic variables; decimal fallback for transcendental functions / overflow / arena limits — a result is never wrongly exact. - 2D rendering: stacked fractions, radicals with a vinculum, raised exponents; the level-1 decimal shown beneath; RAD/DEG angle mode. - Stack-oriented key map; Toolbox stack menu. - Host-tested core (make test); device build via nwlink + arm-none-eabi. - GitHub Actions: CI, releases (rc pre-releases + retention prune) and Pages. - Landing page + interactive N0120 key map; Docker web simulator and a headless real-font screenshot (make screenshot). R↵ identity; slogan; status badges.
1 parent 9beaf18 commit 198dcb4

36 files changed

Lines changed: 2183 additions & 452 deletions

.github/scripts/prune-releases.sh

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Enforce the release-retention policy after a tag is published.
4+
#
5+
# RC (vX.Y.Z-rc / vX.Y.Z-rc.N) pre-release; only the newest RC is kept.
6+
# Publishing a stable release removes ALL RCs.
7+
# patch (vX.Y.Z, Z>0) keeps only the newest release of that X.Y line.
8+
# minor (vX.Y.0) additive; removes nothing on its own.
9+
# major (vX.0.0) for every PREVIOUS major, keep only that major's
10+
# highest minor line; drop the rest.
11+
#
12+
# The keep-set is recomputed from scratch each run (idempotent), so the same rules
13+
# hold no matter the order tags were pushed. The current tag is never deleted.
14+
#
15+
# Usage: prune-releases.sh <current-tag>
16+
# Env: GH_TOKEN (required, gh auth) DRY_RUN=1 (print, don't delete)
17+
# Needs: gh, python3 — the current tag's release MUST already exist on GitHub.
18+
19+
set -euo pipefail
20+
21+
CURRENT="${1:-${GITHUB_REF_NAME:-}}"
22+
[ -n "$CURRENT" ] || { echo "::error::no tag given"; exit 1; }
23+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24+
25+
TAGS="$(gh release list --limit 500 --json tagName --jq '.[].tagName')"
26+
[ -n "$TAGS" ] || { echo "No releases found — nothing to prune."; exit 0; }
27+
28+
# Python decides which tags to remove; one tag per line on stdout.
29+
TO_DELETE="$(printf '%s\n' "$TAGS" | python3 "$HERE/prune_releases.py" "$CURRENT")"
30+
31+
if [ -z "$TO_DELETE" ]; then echo "Nothing to prune (policy already satisfied)."; exit 0; fi
32+
33+
while IFS= read -r tag; do
34+
[ -n "$tag" ] || continue
35+
if [ "${DRY_RUN:-0}" = "1" ]; then
36+
echo "DRY-RUN would remove $tag"
37+
else
38+
echo "Removing release + tag $tag"
39+
gh release delete "$tag" --yes --cleanup-tag
40+
fi
41+
done <<< "$TO_DELETE"

.github/scripts/prune_releases.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/usr/bin/env python3
2+
"""Compute which GitHub releases to delete under the retention policy.
3+
4+
Reads candidate tags (one per line) on stdin, takes the just-published tag as
5+
argv[1], and prints the tags to delete (one per line) on stdout. See
6+
prune-releases.sh for the plain-language policy. The current tag is never
7+
printed, and the keep-set is derived from scratch so the result is idempotent.
8+
"""
9+
import re
10+
import sys
11+
12+
RC_RE = re.compile(r"-rc(\.\d+)?$")
13+
14+
15+
def is_rc(tag):
16+
return RC_RE.search(tag) is not None
17+
18+
19+
def parse(tag):
20+
"""'vX.Y.Z[-rc...]' -> (major, minor, patch) as ints."""
21+
core = tag.lstrip("v").split("-", 1)[0]
22+
parts = (core.split(".") + ["0", "0", "0"])[:3]
23+
return tuple(int(p) if p.isdigit() else 0 for p in parts)
24+
25+
26+
def main():
27+
current = sys.argv[1]
28+
tags = [t.strip() for t in sys.stdin if t.strip()]
29+
rc = [t for t in tags if is_rc(t)]
30+
stable = [t for t in tags if not is_rc(t)]
31+
to_delete = set()
32+
33+
if is_rc(current):
34+
# New RC: keep only this one; leave stable releases alone.
35+
to_delete.update(t for t in rc if t != current)
36+
print_result(to_delete, current)
37+
return
38+
39+
# Stable release: no RC survives.
40+
to_delete.update(rc)
41+
42+
if stable:
43+
# Winner of each "major.minor" line = highest patch.
44+
line_tag = {}
45+
for t in stable:
46+
ma, mi, pa = parse(t)
47+
key = (ma, mi)
48+
if key not in line_tag or pa > parse(line_tag[key])[2]:
49+
line_tag[key] = t
50+
51+
max_major = max(ma for ma, _ in line_tag)
52+
# Highest minor line kept for each major.
53+
major_top_minor = {}
54+
for ma, mi in line_tag:
55+
major_top_minor[ma] = max(mi, major_top_minor.get(ma, mi))
56+
57+
keep = set()
58+
for (ma, mi), tag in line_tag.items():
59+
if ma == max_major or mi == major_top_minor[ma]:
60+
keep.add(tag)
61+
to_delete.update(t for t in stable if t not in keep)
62+
63+
print_result(to_delete, current)
64+
65+
66+
def print_result(to_delete, current):
67+
for t in sorted(to_delete):
68+
if t != current:
69+
print(t)
70+
71+
72+
if __name__ == "__main__":
73+
main()
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env python3
2+
"""Policy scenarios for prune_releases.py. Run: python3 test_prune_releases.py
3+
4+
Each case: (pushed_tag, existing_release_tags, tags_that_must_be_deleted).
5+
"""
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
SCRIPT = Path(__file__).with_name("prune_releases.py")
11+
12+
CASES = [
13+
# patch replaces its whole X.Y line
14+
("v1.2.3", "v1.2.1 v1.2.2 v1.2.3 v1.3.0", "v1.2.1 v1.2.2"),
15+
# minor is additive
16+
("v1.3.0", "v1.2.3 v1.3.0", ""),
17+
# patch removes the .0 of its line too
18+
("v1.3.1", "v1.2.3 v1.3.0 v1.3.1", "v1.3.0"),
19+
# major keeps only the previous major's top minor line
20+
("v2.0.0", "v1.2.3 v1.3.1 v2.0.0", "v1.2.3"),
21+
# each previous major keeps its own top minor line
22+
("v3.0.0", "v1.3.1 v2.5.2 v3.0.0", ""),
23+
("v2.0.0", "v1.1.5 v1.2.0 v1.3.1 v2.0.0", "v1.1.5 v1.2.0"),
24+
# only one RC survives; stable untouched by a new RC
25+
("v0.0.2-rc.3", "v0.0.1 v0.0.2-rc.1 v0.0.2-rc.2 v0.0.2-rc.3", "v0.0.2-rc.1 v0.0.2-rc.2"),
26+
("v1.4.0-rc", "v1.2.3 v1.3.1 v1.4.0-rc", ""),
27+
("v0.0.2-rc", "v0.0.2-rc.1 v0.0.2-rc", "v0.0.2-rc.1"),
28+
# a stable release clears all RCs (and its own lower-patch line members)
29+
("v0.0.2", "v0.0.1 v0.0.2-rc.3 v0.0.2", "v0.0.1 v0.0.2-rc.3"),
30+
]
31+
32+
33+
def run(current, existing):
34+
out = subprocess.run(
35+
[sys.executable, str(SCRIPT), current],
36+
input="\n".join(existing.split()),
37+
capture_output=True, text=True, check=True,
38+
).stdout
39+
return " ".join(sorted(t for t in out.split() if t))
40+
41+
42+
def main():
43+
failures = 0
44+
for current, existing, expected in CASES:
45+
got = run(current, existing)
46+
want = " ".join(sorted(expected.split()))
47+
ok = got == want
48+
failures += not ok
49+
print(f"{'PASS' if ok else 'FAIL'} push {current:14s} del:[{got}]"
50+
+ ("" if ok else f" expected:[{want}]"))
51+
if failures:
52+
print(f"\n{failures} failure(s)")
53+
sys.exit(1)
54+
print(f"\nall {len(CASES)} scenarios passed")
55+
56+
57+
if __name__ == "__main__":
58+
main()

.github/workflows/release.yml

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,50 @@ jobs:
2828
- name: Build device app (.nwa)
2929
run: make
3030

31+
- name: Verify cleaned .nwa
32+
run: |
33+
# The build strips DWARF debug info; assert the result is both
34+
# shrunk AND still a valid relocatable NWA (symtab + eadk sections
35+
# intact, so nwlink can relink it at install time).
36+
sz=$(stat -c%s output/rpn.nwa)
37+
echo "cleaned .nwa size: ${sz} bytes"
38+
[ "$sz" -lt 262144 ] || { echo "::error::.nwa looks unstripped (>256KB)"; exit 1; }
39+
if arm-none-eabi-readelf -SW output/rpn.nwa | grep -q '\.debug'; then
40+
echo "::error::debug sections still present"; exit 1
41+
fi
42+
arm-none-eabi-readelf -SW output/rpn.nwa | grep -q '\.rodata\.eadk_app_icon' \
43+
|| { echo "::error::icon section missing"; exit 1; }
44+
name=$(npx --yes -- nwlink nwa-name output/rpn.nwa)
45+
[ "$name" = "RPN" ] || { echo "::error::nwlink cannot read app name ('$name')"; exit 1; }
46+
echo "OK: stripped, icon intact, nwlink reads the app."
47+
3148
- name: Stage release asset
3249
run: cp output/rpn.nwa rpn-${{ github.ref_name }}.nwa
3350

34-
- name: Create GitHub Release
51+
- name: Publish GitHub Release (idempotent)
3552
env:
3653
GH_TOKEN: ${{ github.token }}
54+
TAG: ${{ github.ref_name }}
3755
run: |
38-
gh release create "${{ github.ref_name }}" \
39-
"rpn-${{ github.ref_name }}.nwa#RPN app (${{ github.ref_name }}) — install via my.numworks.com/apps" \
40-
--title "RPN ${{ github.ref_name }}" \
41-
--notes "RPN calculator for NumWorks.
56+
FILE="rpn-${TAG}.nwa"
57+
# vX.Y.Z-rc / vX.Y.Z-rc.N are pre-releases.
58+
PRERELEASE=""
59+
[[ "$TAG" =~ -rc(\.[0-9]+)?$ ]] && PRERELEASE="--prerelease"
60+
if gh release view "$TAG" >/dev/null 2>&1; then
61+
echo "Release $TAG exists — updating asset."
62+
gh release upload "$TAG" "$FILE" --clobber
63+
else
64+
gh release create "$TAG" "${FILE}#RPN app (${TAG}) — install via my.numworks.com/apps" \
65+
$PRERELEASE \
66+
--title "RPN ${TAG}" \
67+
--notes "RPN calculator for NumWorks.
4268
43-
**Install:** download \`rpn-${{ github.ref_name }}.nwa\` below, then open <https://my.numworks.com/apps>, plug in your calculator and upload the file.
69+
**Install:** download \`rpn-${TAG}.nwa\` below, then open <https://my.numworks.com/apps>, plug in your calculator and upload the file.
4470
4571
See the [project page](https://1e1.github.io/numworks-RPN/) for the full key map."
72+
fi
73+
74+
- name: Prune old releases (retention policy)
75+
env:
76+
GH_TOKEN: ${{ github.token }}
77+
run: bash .github/scripts/prune-releases.sh "${{ github.ref_name }}"

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@ tests/test_engine
55
*.nwa
66
node_modules/
77
.DS_Store
8+
capture/
9+
*.dSYM/
10+
__pycache__/
11+
*.pyc

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented here. This project follows
4+
[Semantic Versioning](https://semver.org/).
5+
6+
## v2.0.0 — Symbolic stack
7+
8+
- The stack value is a general **expression tree** in canonical
9+
polynomial-in-atoms form. Exact results:
10+
- fractions, `k√m`, rational multiples of π, and their sums/products/integer
11+
powers: `8 √ 2 ×``4√2`, `π 2 ÷``π/2`, `√2·√2``2`, `√2+√3`,
12+
`(1+√2)²``3+2√2`, `(√2+√3)(√2−√3)``-1`;
13+
- **nested radicals** (`√(1+√2)`) and **conjugate division**
14+
(`1/(1+√2)``√2−1`, `1/(√3−1)``(1+√3)/2`); symbolic variables.
15+
- Transcendental functions (`sin`, `ln`), overflow, or exceeding the on-device
16+
arena fall back to a decimal — a result is never *wrongly* exact.
17+
- STL-free engine over a fixed arena with a compacting garbage collector;
18+
overflow-checked arithmetic; device-sized pools.
19+
- **2D rendering** on the stack: stacked fractions, radicals with a vinculum and
20+
raised exponents; every level shows its exact form and level 1 also shows its
21+
decimal approximation (``).
22+
- π is an exact constant; `→Dec` (`Ans`) forces the decimal form.
23+
- Factorial now uses `gamma(n+1)` for large or non-integer arguments instead of
24+
an unbounded loop.
25+
- All arithmetic remains overflow-checked `int64` with a decimal fallback.
26+
27+
## v1.0.0 — First release
28+
29+
- Reverse Polish Notation calculator as a NumWorks external app (`.nwa`).
30+
- Exact rational arithmetic for `+ − × ÷` and integer powers; IEEE double for
31+
transcendental functions.
32+
- Stack-oriented key map: operator keys act on the stack, RPN-unused keys become
33+
stack operations, `Toolbox` opens a stack menu; RAD/DEG angle mode.
34+
- Pure, host-tested numeric core (`make test`); device build via `nwlink` and
35+
`arm-none-eabi`.
36+
- GitHub Actions for CI, tagged releases and a GitHub Pages site with an
37+
interactive N0120 key map.

0 commit comments

Comments
 (0)