6
2
Fork
You've already forked saywhere
0

checksum: signal the method, weight by position, reduce mod 31 #21

Merged
hugooconnor merged 9 commits from geo-qr-pin into main 2026年07月11日 13:10:15 +02:00

Fixes three defects in the terminal checksum, each of which let a corrupted phrase validate. Verified across the spec, the Guile reference, and the web app.

What was wrong

Dual acceptance. The spec required a validator to compute both the CRC-8 and the additive checksum and accept either. Two independent chances to pass roughly doubles the residual false-negative rate — measured 6.01% vs 3.03% over 200k random substitutions.

Transposition was never detected. The additive checksum was a plain letter sum, which is order-invariant, so every permutation of a phrase validated. Measured detection: 0.00%. This sat directly on the emergency-radio path the additive method exists to serve.

The modulus was wrong. With position weights 1..6, mod 32 leaves gcd(2,32)=2, gcd(4,32)=4, gcd(6,32)=2, so a substitution in the 4th word went undetected 12.65% of the time.

The fixes

  1. Method signaling. The two methods now draw from disjoint vocabularies, so the trailing word identifies its algorithm and the validator computes exactly one checksum. MUST accept either is replaced by an explicit MUST NOT.
  2. Position weighting. index = (Σ i·lettersum(wordi)) mod M. Transposition detection goes 0% → ~97%.
  3. Prime modulus. M = 31. Every weight 1..6 and every weight difference is invertible, so no hole exists to move. (All-odd weights fix substitution but make every difference even, relocating the hole onto transposition.)

Detection is now ~3.2%, uniform across substitution, transposition and omission, at every word position.

Vocabularies rebuilt

The old set was screened for spelling disjointness only. 23 of its 32 words sat within one phoneme of a BIP-39 location word — including gray/grape, and grape is the location word in every worked example — plus quail/whale and seal/sea.

BIP-39's four-letter identity rule is deliberately not applied. It bundles a written hazard (crow/crowd, which a phoneme test misses) with a spoken one (stork/story, which a prefix test misses), catches neither cleanly, and across six Latin-script wordlists rejects Romance cognates like emerald and crimson that are neither. SayWhere never truncates a word, so the rule buys nothing and costs most of the usable vocabulary. It's replaced by an explicit prefix rule and a phoneme-distance rule, plus spelling and familiarity criteria.

scripts/checksum_vocab.py (new) builds, audits and verifies the vocabularies against all ten official BIP-39 wordlists, the CMU Pronouncing Dictionary, and a spoken-English frequency corpus. It reports 0/32 and 0/31 failures for the adopted sets, and scores the superseded ones so the regression is visible.

Verification

  • Guile, Rust and the web app agree on all 144 phrases (24 locations ×ばつ 6 precisions) under both methods.
  • Every worked value was re-parsed out of the spec and recomputed; the field card matches CHECKSUM_ADD_31 index-for-index.
  • Guile suites pass; the companion Rust PR passes 151 tests.

Known residual, documented on purpose

sum("column") − sum("grape") = 31 exactly, so swapping the first two words shifts the index by 0 mod 31 and column.grape.hip.tremor validates. It's the 1-in-31 residual landing on our own example. Rather than switch to mod 29 to make the demo look clean, the spec carries it as a labelled test vector and Rust pins it as a test. A terminal checksum reduces the chance of accepting a corrupted phrase; it does not eliminate it.

Breaking change

Location-only phrases are unaffected (grape.column.hip still resolves). Previously-shared checksummed phrases break:

phrase before after
grape.column.hip ok ok
grape.column.hip.toad ok rejected — no longer a checksum word
kit.puzzle.ruby ok rejected — ruby moved index; CRC is now burgundy

The kit.puzzle.ruby case surfaces as "checksum failed" rather than "unknown word", which reads like corruption rather than a spec revision. Worth a note in release comms.

Canonical example is now grape.column.hip.hermit (CRC-8) or grape.column.hip.tremor (additive).

Scope note

This branch also carries two pre-existing UI commits (171ee6a mobile wayfinding overlay, bf0a689 onboarding card) that predate the checksum work and were already on origin/geo-qr-pin. They touch web/index.html and the UI layer of web/js/app.js, not the checksum layer.

Companion PR: anuna/saywhere-rschecksum-and-altitude-conformance.

🤖 Generated with Claude Code

https://claude.ai/code/session_017R8AwqxwAq8Av8GMZvLEPT

Fixes three defects in the terminal checksum, each of which let a corrupted phrase validate. Verified across the spec, the Guile reference, and the web app. ## What was wrong **Dual acceptance.** The spec required a validator to compute *both* the CRC-8 and the additive checksum and accept either. Two independent chances to pass roughly doubles the residual false-negative rate — measured **6.01% vs 3.03%** over 200k random substitutions. **Transposition was never detected.** The additive checksum was a plain letter sum, which is order-invariant, so *every permutation* of a phrase validated. Measured detection: **0.00%**. This sat directly on the emergency-radio path the additive method exists to serve. **The modulus was wrong.** With position weights `1..6`, `mod 32` leaves `gcd(2,32)=2`, `gcd(4,32)=4`, `gcd(6,32)=2`, so a substitution in the 4th word went undetected **12.65%** of the time. ## The fixes 1. **Method signaling.** The two methods now draw from disjoint vocabularies, so the trailing word identifies its algorithm and the validator computes exactly one checksum. `MUST accept either` is replaced by an explicit `MUST NOT`. 2. **Position weighting.** `index = (Σ i·lettersum(wordi)) mod M`. Transposition detection goes 0% → ~97%. 3. **Prime modulus.** `M = 31`. Every weight `1..6` and every weight *difference* is invertible, so no hole exists to move. (All-odd weights fix substitution but make every difference even, relocating the hole onto transposition.) Detection is now **~3.2%, uniform** across substitution, transposition and omission, at every word position. ## Vocabularies rebuilt The old set was screened for spelling disjointness only. **23 of its 32 words sat within one phoneme of a BIP-39 location word** — including `gray`/`grape`, and `grape` is the location word in every worked example — plus `quail`/`whale` and `seal`/`sea`. BIP-39's four-letter identity rule is **deliberately not applied**. It bundles a written hazard (`crow`/`crowd`, which a phoneme test misses) with a spoken one (`stork`/`story`, which a prefix test misses), catches neither cleanly, and across six Latin-script wordlists rejects Romance cognates like `emerald` and `crimson` that are neither. SayWhere never truncates a word, so the rule buys nothing and costs most of the usable vocabulary. It's replaced by an explicit prefix rule and a phoneme-distance rule, plus spelling and familiarity criteria. `scripts/checksum_vocab.py` (new) builds, audits and verifies the vocabularies against all ten official BIP-39 wordlists, the CMU Pronouncing Dictionary, and a spoken-English frequency corpus. It reports **0/32 and 0/31 failures** for the adopted sets, and scores the superseded ones so the regression is visible. ## Verification - Guile, Rust and the web app agree on **all 144 phrases** (24 locations ×ばつ 6 precisions) under **both** methods. - Every worked value was re-parsed *out of the spec* and recomputed; the field card matches `CHECKSUM_ADD_31` index-for-index. - Guile suites pass; the companion Rust PR passes 151 tests. ## Known residual, documented on purpose `sum("column") − sum("grape") = 31` exactly, so swapping the first two words shifts the index by `0 mod 31` and **`column.grape.hip.tremor` validates**. It's the 1-in-31 residual landing on our own example. Rather than switch to `mod 29` to make the demo look clean, the spec carries it as a labelled test vector and Rust pins it as a test. A terminal checksum reduces the chance of accepting a corrupted phrase; it does not eliminate it. ## Breaking change Location-only phrases are unaffected (`grape.column.hip` still resolves). **Previously-shared checksummed phrases break:** | phrase | before | after | |---|---|---| | `grape.column.hip` | ok | ok | | `grape.column.hip.toad` | ok | rejected — no longer a checksum word | | `kit.puzzle.ruby` | ok | rejected — `ruby` moved index; CRC is now `burgundy` | The `kit.puzzle.ruby` case surfaces as "checksum failed" rather than "unknown word", which reads like corruption rather than a spec revision. Worth a note in release comms. Canonical example is now `grape.column.hip.hermit` (CRC-8) or `grape.column.hip.tremor` (additive). ## Scope note This branch also carries two pre-existing UI commits (`171ee6a` mobile wayfinding overlay, `bf0a689` onboarding card) that predate the checksum work and were already on `origin/geo-qr-pin`. They touch `web/index.html` and the UI layer of `web/js/app.js`, not the checksum layer. Companion PR: `anuna/saywhere-rs` — `checksum-and-altitude-conformance`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017R8AwqxwAq8Av8GMZvLEPT
New users landed on the map with no explanation of what SayWhere does.
Add a hero explainer at the top of the panel (concept + concrete example
+ how-to), and a single MutationObserver on #results that toggles
body.has-result to hide it across every render path (encode/decode/route)
and bring it back on Clear.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FR3DwXFv7WcnHRa5DCnhyM 
Move the arrow + distance readout out of the bottom-sheet peek bar into a
large accent banner fixed across the top of the map on mobile. Reuses the
existing updateWay/applyRotation/clearWay paths (new #way-top joins the
peekWay/wayfinding loops), with a bigger white arrow that still rotates to
the destination/device heading. Desktop keeps its in-panel .wayfinding
banner; the peek bar now shows just the words.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FR3DwXFv7WcnHRa5DCnhyM 
The terminal checksum had three defects, each of which let a corrupted
phrase validate.
Dual acceptance. The spec required a validator to compute both the CRC-8
and the additive checksum and accept either. Two independent chances to
pass roughly doubles the residual false-negative rate, from 1/32 to 2/32
(measured 6.01% vs 3.03% over 200k random substitutions). Fix: the two
methods now draw from disjoint vocabularies, so the trailing word says
which algorithm produced it and the validator computes exactly one.
Transposition. The additive checksum was a plain letter sum, which is
order-invariant, so every permutation of a phrase validated -- 0.00%
transposition detection, on the emergency-radio path the method exists
to serve. Fix: weight each word's letter sum by its 1-based position.
Modulus. With weights 1..6, mod 32 leaves gcd(2,32)=2, gcd(4,32)=4 and
gcd(6,32)=2, so a substitution in the 4th word went undetected 12.65% of
the time. All-odd weights repair substitution but make every weight
difference even, moving the hole onto transposition. Fix: reduce mod 31.
31 is prime, so every weight and every weight difference is invertible
and no hole exists to move. Detection is now ~3.2%, uniform across
substitution, transposition and omission, at every word position.
Vocabularies are rebuilt from scratch. The old set was chosen for
spelling disjointness only: 23 of its 32 words sat within one phoneme of
a BIP-39 location word, including gray/grape -- and "grape" is the
location word in every worked example -- plus quail/whale and seal/sea.
BIP-39's four-letter identity rule is deliberately not applied. It
bundles a written hazard (crow/crowd, which a phoneme test misses) with a
spoken one (stork/story, which a prefix test misses), catches neither
cleanly, and across six Latin-script wordlists rejects Romance cognates
like emerald and crimson that are neither. SayWhere never truncates a
word, so the rule buys nothing and costs most of the usable vocabulary.
It is replaced by an explicit prefix rule and a phoneme-distance rule,
plus spelling and familiarity criteria.
scripts/checksum_vocab.py builds, audits and verifies the vocabularies
against all ten official BIP-39 wordlists, the CMU pronouncing
dictionary, and a spoken-English frequency corpus. It reports 0/32 and
0/31 failures for the adopted sets.
Canonical example is now grape.column.hip.hermit (CRC-8) or
grape.column.hip.tremor (additive). The spec carries the ~1-in-31
residual false negative as a labelled test vector: the letter sums of
"column" and "grape" differ by exactly 31, so column.grape.hip.tremor
validates. A terminal checksum reduces the chance of accepting a
corrupted phrase; it does not eliminate it.
Guile, Rust and the web app agree on all 144 phrases (24 locations x 6
precisions) under both methods.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8AwqxwAq8Av8GMZvLEPT 
Reported: "khaki": Word not found in wordlist.
The web app never produced that phrase -- it encodes with includeChecksum
= false and no call site passes true, so it has never emitted a checksum
at all. The legacy phrases came from the README, which documented the old
32-word vocabulary and used grape.column.hip.toad in ten examples, and
from the tracked draft-02 .txt/.xml, which are generated artifacts that
were never regenerated after the .md changed. Both are fixed here.
Old links, QR codes and bookmarks do exist in the wild, though, and the
location words never changed, so a legacy phrase still names the right
place. decode() now recognises the superseded vocabulary, strips the
trailing word, resolves the location, and reports the checksum as
unverified rather than throwing. It never claims such a checksum is
valid. "amber", "ruby" and "lilac" survive into the current vocabulary at
different indices, so a valid legacy phrase can present as a mismatch;
those are checked against the legacy vocabulary before being rejected.
This is safe only because no word of the superseded vocabulary is a BIP-39
word, so a trailing legacy word can never swallow a location word. The
vocabulary before it cannot be tolerated the same way: 22 of its 32 words
(whale, gold, orange, eagle, fox ...) are BIP-39 words, and
"grape.column.hip.whale" is genuinely ambiguous.
Genuine failures still fail: a wrong current checksum word, a transposed
additive phrase, and an unknown word all raise as before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8AwqxwAq8Av8GMZvLEPT 
On a failed terminal-checksum validation the API always reported the
CRC-8 expectation, even when the supplied word came from the additive
vocabulary. A hand-computed phrase whose checksum was wrong would be told
to use a CRC-8 word it could not compute.
The checksum word identifies its method, so report that method's expected
word.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8AwqxwAq8Av8GMZvLEPT 
Replace fabricated Spanish/French URN examples with real BIP-39
phrases; specify NFC + percent-encoding for non-ASCII location words
and extend the ABNF accordingly; scope the ASCII/3-8-letter wordlist
criteria to English (Spanish/French run to 9-10 letters with
diacritics); fix 63-vs-64 checksum-word and 32-word-vocabulary
leftovers; correct the additive field-card arithmetic bounds; promote
the IPR section to top level and cite RFC 8179 properly; reorder test
cases 5/6; switch to xml2rfc v3; fix all sourcecode lines over 72
chars; refresh dates. Rebuilt .xml/.txt (kramdown-rfc + xml2rfc,
zero warnings).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8AwqxwAq8Av8GMZvLEPT 
PRECISION_SIZES showed ~630km/~156km/~610m/~19m/~5m/~1.5m; several of
those correspond to odd geohash character counts no phrase can produce
(156km is a 3-char cell, 5m a 9-char cell). Use the draft-02 table
values (~900km/~28km/~900m/~27m/~90cm/~2.7cm, geometric mean of the
cell dimensions) and fix the static default slider label to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8AwqxwAq8Av8GMZvLEPT 
Pre-existing working-tree edit (from the checksum-vocabulary rework):
updates the -01 markdown source's colour/animal checksum words to the
phonetically-distinct set. Note the published -01 on ietf.org is
unaffected; this only touches the local source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8AwqxwAq8Av8GMZvLEPT 
Define a canonical wordlist serialization (UTF-8 words, LF-terminated)
as the sole digest input, demoting container digests to informative;
permanently bind each URN language tag to one wordlist digest so the
tag alone identifies the wordlist; specify canonical altitude form
(leading zeros, trailing fraction zeros, negative zero) with
producer/consumer rules; define percent-encoding generation and
consumption pipelines (NFC + uppercase hex) so lexical equivalence
never decodes triplets, per RFC 8141 s3.1; pin the BIP-39 reference
and wordlist URL to an immutable commit (bytes verified against the
published digest). Rebuilt .xml/.txt, zero warnings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8AwqxwAq8Av8GMZvLEPT 
Sign in to join this conversation.
No reviewers
Labels
Clear labels
No items
No labels
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
anuna/saywhere!21
Reference in a new issue
anuna/saywhere
No description provided.
Delete branch "geo-qr-pin"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?