Repository navigation
Boat Matching Strategy
Boat matching determines whether two source records refer to the same physical boat. Matching is deterministic and relies on an existing alias resolution engine (Aliases.java) and data store (DataStore.java).
Key Principles:
- No Fuzzy Matching: No Jaro-Winkler or probabilistic scoring. Matches are exact after normalization or via explicit alias rules.
- Implicit Normalization: Certain transformations (e.g., country prefix stripping) are handled implicitly by the engine, not requiring explicit YAML entries.
- Design Flexibility: Design mismatches are handled via overrides, "ignored" design logic, and time-windowed resolution, not just hard failures.
-
Centralized Aliases: All alias logic resides in
aliases.yamlandAliases.java. Boat records do not store their own alias lists.
Rules:
- Uppercase all letters.
- Strip all non-alphanumeric characters (spaces, hyphens, dots).
-
Country Prefix Handling: The country prefix (e.g.,
AUS,GBR) is preserved in the normalized string but stripped implicitly during matching.-
aus-012345→AUS012345(Normalized) -
AUS 1234→AUS1234(Normalized) -
012345→012345(Normalized)
-
Matching Logic (Implicit):
- The matching engine (
Aliases.java) automatically treatsAUS1234and1234as equivalent. - Leading zeros are stripped numerically during comparison.
-
AUS01234matches01234(prefix stripped, leading zero stripped). -
AUS01234matches1234(prefix stripped, leading zero stripped). -
AUS01234matchesAUS1234(prefix stripped, leading zero stripped).
-
-
Correction: The previous spec incorrectly stated
AUS01234would not matchAUS1234. Under the current implementation, they do match because both reduce to1234.
Rules (IdGenerator.normaliseName):
- Strip decorative suffixes after the last
-(see below). - Lowercase all letters.
- Strip ALL non-alphanumeric characters.
-
No sponsor-prefix stripping: Sponsor prefixes are preserved unless an explicit alias exists.
-
Komatsu Azzuro→komatsuazzuro -
Azzuro→azzuro -
Result:
komatsuazzuro≠azzuro(Requires explicit alias).
-
Decorative suffix stripping (IdGenerator.stripStandardSuffixes):
The trailing portion after the last - is dropped when it matches one of the
standard Australian handicap-division markers. The comparison is done in
fully-normalised space — the portion after the last - is lowercased and
non-alphanumerics dropped before being checked against the suffix list — so the
raw form can carry any mixture of case and internal whitespace and still match.
"Foobar - GM", "Foobar-gm", "Foobar - Under 17", and "Foobar -UNDER 18"
all strip cleanly. The strip is iterative (Boat - GM - U18 → Boat). Both the
persisted display name and the boat id reflect the stripped form, so a
"Foobar - GM" import and a "Foobar" import collapse into a single boat
record named "Foobar".
| Suffix tokens (normalised) | Meaning |
|---|---|
l, m, gm, ggm, gggm
|
Grand-Master class markers |
u16, u17, ... , u21, under16, ... , under 21
|
Youth division markers |
Add to STANDARD_SUFFIXES in IdGenerator.java when a new convention appears
in source data. Entries are exact normalised strings (lowercase, alphanumeric
only) — the source data's case and inner spacing are normalised before lookup.
Two raw names that share the same sail number and design are treated as the
same boat when their IdGenerator.nameMatchKey collapses to the same non-empty
string. This handles the standard nuisance variants — "Sticky" vs "Sticky II"
vs "Sticky 2", "Goat" vs "The Goat", "The Sticky 2 - GM" vs "Sticky",
etc. — without requiring a hand-written aliases.yaml entry for each one.
nameMatchKey algorithm (operates on the raw form so word boundaries survive):
- Strip decorative suffixes (
stripStandardSuffixes). - Lowercase.
- If the result starts with
"the "(article + space), drop those four characters. The trailing space is required, so"Thelma"and"Thereby"are left alone. - Iteratively trim a trailing whitespace-separated Arabic or Roman numeral
token (
\s+(\d+|[ivxlcdm]+)). Roman numerals are accepted in either case. The whitespace requirement protects embedded letter runs —"Tivoli"keeps its"li","Anna"keeps its"a". - Strip non-alphanumerics.
| Raw name | matchKey | Notes |
|---|---|---|
Goat |
goat |
|
The Goat |
goat |
leading "the " dropped |
Sticky 2 |
sticky |
trailing Arabic dropped |
Sticky II |
sticky |
trailing Roman dropped |
The Sticky 2 - GM |
sticky |
all three rules combined |
Tivoli |
tivoli |
no whitespace before "li" → kept |
Thelma |
thelma |
no space after "the" → kept |
Canonical-name picker (IdGenerator.preferredDisplayName): When an
equivalence-class merge happens (either at import time or in the startup repair
pass), the canonical display name is built from all observed raw variants:
- For each variant, drop standard suffixes, then split into
(body, numeral)wherenumeralis the trailing whitespace-separated Arabic / Roman token. - The
bodywith the greatest character length wins (case-insensitive ties → first input wins, so callers can pass the incoming name first to prefer fresh data). - If any variant supplied an Arabic numeral, use that; otherwise use any Roman numeral; otherwise no numeral. Arabic always beats Roman.
| Input variants | Canonical | Resulting boat id (sail S, no design) |
|---|---|---|
["Goat", "The Goat"] |
The Goat |
S-thegoat |
["Sticky", "Sticky II"] |
Sticky II |
S-stickyii |
["Sticky 2", "Sticky II"] |
Sticky 2 |
S-sticky2 (Arabic wins) |
["Foobar - GM", "Foobar"] |
Foobar |
S-foobar |
["The Sticky 2 - GM", "Sticky"] |
The Sticky 2 |
S-thesticky2 |
Scope of the rule: Match-key equivalence requires identical sail numbers (after AUS-prefix / leading-zero stripping) AND compatible designs (same designId, or at least one side design-less — same rule as exact-name matching). A name-only match across the database would be far too loose.
Rules:
- Lowercase all letters.
- Strip all non-alphanumeric characters.
-
Design Overrides:
DataStore.javasupports time-windowed design overrides (resolveDesignOverride). If a boat changes design (e.g., refit), the override takes precedence for the specific date range. - Ignored Designs: See Ignored Designs below.
Some "designs" in the source data are not real one-design classes — generic labels
like yacht, custom, monohull, or junk text. Treating these as designs
fragments a boat's history: the same physical boat appears once as a design-less
record and again as a …-yacht record, and neither sees the other's races.
To prevent this, designs can be marked ignored:
-
Storage: the
ignored:list inhpf-data/config/design.yamlis the single source of truth. It can also be toggled at runtime viaDataStore.setDesignIgnored, which writes back todesign.yamland reloads the design catalogue. -
Lookup:
DataStore.isDesignIgnored(designId)answers whether a design is currently ignored (delegates todesignCatalogue.isIgnored).
Effect on matching (findOrCreateBoat):
-
Incoming record: after design-alias and override resolution, if the resolved
design is ignored it is cleared —
designIdbecomes""andrawDesignbecomesnull. The incoming boat is matched as design-less. - Candidate records: during the sail+name candidate scan, any existing boat whose stored design is now ignored is skipped entirely. It is treated as a stale orphan so it cannot block or split an otherwise unambiguous match.
-
Design creation:
findOrCreateDesignreturnsnullfor an ignored design id, so a new ignored-design record can never be created.
Cascade when a design is newly ignored (setDesignIgnored(id, true)):
cascadeIgnoreDesign rewrites every boat currently carrying that design:
- The boat id is regenerated without the design suffix (
sailNo-name-design→sailNo-name) anddesignIdis cleared. - If a boat already occupies the target id, the two are merged — certificates deduplicated, sources unioned, club ids merged, finisher references rewritten from the old id to the target id.
- Otherwise the boat is simply renamed in place, with finisher references and the club-config boat-id mapping rewritten.
- An
Ignored:<designId>entry is added to the affected boat'ssourcesfor traceability.
Un-ignoring a design (setDesignIgnored(id, false)) only flips the flag; boats
already de-designed by a previous cascade are not restored — the original
design is unrecoverable. Callers must save() to persist entity-level changes.
The matching logic is encapsulated in Aliases.java and DataStore.java.
Compute normalized sail number and name for both records.
- Name: Must match exactly after normalization.
- Sail Number: Match after implicit prefix/leading-zero stripping.
-
Design:
- If both have designs: Must match (or resolve via
design.yamlaliases). - If the incoming record's design is "ignored": treat the incoming record as
null(design-less). - If a candidate boat's stored design is "ignored": skip that candidate — it is a stale orphan and never matches.
- If one has
nulland the other has a design: Match (the design-less boat absorbs the design). -
Conflict: If both have different, non-ignored designs, check for Design Overrides in
DataStore. If no override exists, this is a potential mismatch requiring manual review.
- If both have designs: Must match (or resolve via
If Step 2 found no exact-name candidates, scan again under the same sail+design
constraint but compare names via IdGenerator.nameMatchKey (see §1.2a). This
catches the standard variants — "Sticky" ≡ "Sticky II" ≡ "Sticky 2",
"Goat" ≡ "The Goat" — without requiring a hand-written aliases.yaml entry.
- The match-key pass runs only when the exact-name pass returned nothing, so it can never widen a unique exact hit into ambiguity.
- A unique match-key hit becomes the resolved boat. If the canonical name
(
preferredDisplayName(incoming, candidate)) differs from the candidate's stored name, the candidate is renamed in place: new boatId,rewriteFinisherBoatIdon all races,ClubLoader.remapBoatIdfor club overrides, and the supplanted variants are written toaliases.yamlso subsequent imports of the old form keep resolving here. - Multiple match-key hits → ambiguous, logged, returns
null.
If direct match fails, consult aliases.yaml via Aliases.java.
Alias Types Supported:
-
Sail Number Alias:
AUS1234→5656 -
Name Alias:
Raging Bull→rbull -
Design Alias:
sydney36od→sydney36cr(handled indesigns:section) -
Implicit Prefix Alias:
AUS1234≡1234(Handled automatically, no YAML entry needed).
Alias Resolution Logic:
- The engine checks for exact matches on normalized keys.
- If a partial match exists (e.g., sail number matches, name differs), it checks if a name alias bridges the gap.
- Combinatorics: The engine handles the combination of sail-only and name-only aliases internally to avoid explosion.
-
Source Tracking: Every import adds a
<source>:<rawDesign>tag toBoat.sourcesfor traceability. -
Merge Logic:
- If a match is found, the records are merged.
-
Boat ID Upgrade: If a design-less boat acquires a design (via match or override), its
boatIdchanges.DataStore.javaautomatically rewrites allFinisher.boatIdreferences in historical races (critical load-bearing logic). -
Orphan Absorption:
Aliases.addAliases()cleans up stale alias entries when identities are merged.
The matching algorithm in §2 is layered with overrides (admin-controlled YAML
config) and repair passes (run on every DataStore.start()). These layers exist
because boat identity changes over time: an importer's source data can rename a
boat, change its sail number, or report a different design, and the system must
reconcile those changes against records it created from earlier data. Without
the layers below, every such source-data change would create a duplicate
record.
The DataStore call that turns (rawSail, rawName, rawDesign, date) into a
canonical Boat runs these steps in order:
-
Decorative suffix strip —
IdGenerator.stripStandardSuffixes(rawName)removes"- GM","- U18", etc. from the incoming raw name so the persisted display name never carries them. -
Input normalisation —
IdGenerator.normaliseSailNumber,IdGenerator.normaliseName(which strips the same suffixes internally for ID consistency). -
Boat alias lookup —
Aliases.lookupBoat(sailNo, name)returns the canonical(sail, name, displayName)if the inputs are an alias of a different identity. -
Active stale-record scan — if the alias resolved to a different
canonical, scan
boatsfor any orphan record still sitting under the pre-alias(sail, name)and merge / rename it into the canonical id immediately. Mirrors the startup auto-fix-stale pass but runs at import time, so analiases.yamledit heals without waiting for a restart. -
Design alias resolution —
Aliases.resolveDesignAlias(normaliseName(rawDesign)). -
Design override resolution —
Designs.resolveDesignOverride(canonSail, canonName, date). Keyed by post-aliasnormSail|normNamewith optional date range. -
Ignored-design check —
isDesignIgnored(designId)clears the design so the boat matches as design-less. -
Existing-boat search (Phase 1) in
boats.values()by post-alias sail+name with the four design-compatibility branches in §2 step 2. -
Name-equivalence search (Phase 1.5) — runs only when Phase 1 found
nothing. Scans for a unique boat sharing the same sail+design whose name
collapses to the same
nameMatchKey(§1.2a). On a hit,applyMatchKeyResultrenames the candidate topreferredDisplayName(incoming, candidate), absorbs the supplanted variants intoaliases.yaml, and returns the renamed boat. -
BoatId generation —
IdGenerator.generateBoatId(sail, name, design). -
BoatId-based club override —
ClubCatalogue.resolveBoatIdOverride(boatId). -
Sail+name club override fallback —
ClubCatalogue.resolveClubOverride(normSail, rawName).
| Lookup | Key |
|---|---|
aliases.yaml boat aliases (boats:) |
canonical normSail-normName with alias entries containing optional sailNumber / name
|
aliases.yaml design aliases (designs:) |
canonical normalised design id |
design.yaml boatDesignOverrides[].boats[]
|
normSail | normName POST-boat-alias with optional from/until
|
design.yaml ignored: / excluded:
|
canonical normalised design id |
clubs.yaml boatIdOverrides
|
full boatId (post-design) |
clubs.yaml legacy sail+name club overrides |
normSail + rawName
|
clubs.yaml noclub
|
full boatId
|
Important: boatDesignOverrides is keyed by the post-alias sail+name.
If you have alias 1088 / nortonwhitecorum → 1088 / Corum in aliases.yaml
and want a design override, the override entry in design.yaml must use the
canonical form 1088 / Corum, not the alias 1088 / nortonwhitecorum.
DataStore.start() runs the following repair passes after loading boats /
designs / clubs / races into memory:
-
noclub correction (pre-migration) — clears
clubIdson any boat both in thenoclubconfig and currently carrying a club. -
Design alias correction — boats whose stored
designIdis itself an alias get migrated to the canonical design id (merging into any existing canonical record). -
Design override correction — boats whose stored
designIddisagrees with an active undated entry inboatDesignOverridesget migrated to the override design (merging where the override target already exists). -
Auto-fix stale boats — for each boat,
aliases.lookupBoat(sail, name)on the boat's pure normalised name; if it resolves to a different canonical, merge or rename in place. Also absorbs the orphan alias entry fromaliases.yaml. This is the safety net for boats created before an alias entry existed. - Design upgrade — merges any design-less boat into a design-bearing sibling with the same sail+name.
-
Suffix-strip normalisation (Phase A) — re-normalises any boat whose
stored display name still carries one of the standard suffixes (§1.2).
Records persisted as
"Foobar - GM"are renamed to"Foobar"with a new boatId; finisher references and the club-config boat-id mapping are rewritten; the original suffixed name is absorbed intoaliases.yaml. Merges into the canonical id if one already exists. -
Name-equivalence collapse (Phase B) — groups remaining boats by
(sail, designId, nameMatchKey); each multi-boat group is merged into a single canonical boat picked bypreferredDisplayName. Absorbs the variants intoaliases.yamlso future imports of the old names continue to land on the canonical. Catches pre-existing duplicates that were created beforefindOrCreateBoatlearned aboutnameMatchKey. -
noclub correction (post-migration) — re-runs the noclub check because
the design migrations above may have produced new boatIds, and a
noclubentry typically targets the post-migration id. -
Sanity scan —
computeStaleBoatViolations()re-checks every boat against the four known violation patterns (alias-stale name, alias-stale sail, design-override drift, stale noclub assignment, design-alias drift) and emitsLOG.errorfor each residual; the list is cached forfindStaleBoatViolations()and theGET /api/healthendpoint.
- Idempotent re-import: running an importer twice in a row produces no new boats and no merges.
-
Alias edit + restart heals stale records: any boat whose
(sail, name)is now an alias of another canonical is auto-merged or auto-renamed by the startup auto-fix-stale pass on the next restart. -
Alias edit + import (no restart) also heals: the active stale-record
scan inside
findOrCreateBoat(§2B.1 step 4) catches in-memory orphans at import time so an alias edit takes effect immediately on the next import, without requiring a restart. -
Name-variant imports do not duplicate: importing
"Sticky"then later"Sticky II"under the same sail+design produces a single boat record, not two — the second import triggers Phase 1.5 (§2B.1 step 9), renames the existing record to the longer canonical, and persists the supplanted variant as an alias. -
Design override edit + restart heals: any boat whose
designIddisagrees with an active override is migrated by the design-override correction pass on the next restart. -
noclubpropagates to post-migration boatIds: the noclub correction runs both before AND after the design migrations, so anoclubentry targeting the post-migration id is applied even when the migration is triggered by the same startup. -
GET /api/healthis the visibility surface: if it returns{"ok": true, "staleBoats": []}, the dataset is clean; if not, the returned list names every residual violation.
The sail number is the primary matching key. Some sources, however, publish results pages with no Sail-No column at all — several TopYacht clubs (e.g. RYCT) list only the boat name. The importer must still attach those finishers to the right boat, but it must never invent a brand-new design-less boat from a bare name, because names are far less unique than sail numbers.
- Sail-No is treated as an optional column. A division is no longer skipped when the sail column is missing; only name + time (or finish/start) are required.
- When merging rows across result pages, rows are keyed by sail number when
present, and otherwise by
"name:" + normalisedName. -
resolveBoatchooses the lookup path:-
Sail present → normal
DataStore.findOrCreateBoat(sail + name keyed). -
Sail absent →
DataStore.findBoatByNameAndClub(name, organisingClubId). If that returns empty (no match, or ambiguous), the finisher row is skipped with a WARN — no boat is created.
-
Sail present → normal
A deliberately strict, read-only lookup:
- Scope is restricted to boats already known to belong to the organising
club (
Boat.hasClub(clubId)). A name-only match across the whole database would be far too loose. - Matching is case-insensitive on the normalised boat name.
- Candidates whose stored design is currently ignored are skipped (mirrors
the orphan-tolerance in
findOrCreateBoat). - Returns the boat only when exactly one candidate matches. Zero matches or
two-plus matches both return
Optional.empty(), so an ambiguous name never silently picks a boat.
This keeps the no-sail path purely additive: it can link a finisher to an already-known club boat, but it can never fabricate identity from a name alone.
When a boat with no design matches multiple boats with different designs, or when designs conflict without an override.
-
No Formal Queue: A formal
UnmatchedBoatQueuewith UI states (PENDING/RESOLVED) has been descoped. - Logging: Ambiguous matches are logged to a file for manual review.
-
Resolution: Admins manually edit
aliases.yamlordesign.yamlto resolve conflicts, then re-run the importer. -
Design Overrides: For boats that physically change designs, use
designCatalogue.resolveDesignOverrideinDataStoreto map specific sail/name/date combinations to the correct design.
-
Single Source of Truth:
hpf-data/config/aliases.yaml. -
No Per-Boat Aliases: Boat records do not store
List<TimedAlias>. Embedding aliases on boats invites drift. -
Timestamping: If provenance is needed, add
addedAt/createdByfields to YAML entries, not the boat record.
- Admin identifies a mismatch or ambiguity.
- Admin edits
aliases.yaml(boat aliases) ordesign.yaml(design overrides / ignores) — typically by clicking through the admin UI, which writes the YAML. - Either:
- Restart the server — startup repair passes (§2B.3) merge any stale records.
-
Or re-run the relevant importer — the active stale-record scan in
findOrCreateBoat(§2B.1 step 4) repairs at import time.
-
DataStorehandles ID upgrades and finisher reference rewriting automatically. - Verify clean state with
GET /api/health— expect{"ok": true, "staleBoats": []}.
These are the four failure patterns repeatedly seen in production. Each is
documented with the repair pass that owns it. If /api/health reports a
violation that none of the documented passes fixes, file a bug — the matching
algorithm has a gap.
| Stale pattern | Cause | Repair pass that fixes it |
|---|---|---|
Old-canonical name persists after the canonical was changed (e.g. boat 1088-nortonwhitecorum-farr36 after canonical was flipped to 1088-corum) |
YAML edit (often via admin merge) demoted the previous canonical to an alias, but the original record on disk was not in memory at that moment |
Auto-fix stale boats (§2B.3 step 4). The active scan in findOrCreateBoat also catches this at import time. |
Old-canonical sail number persists after canonical sail was changed (e.g. 44361AUS4788-okavangodelta-j24 after canonical became 4788) |
Same as above but the sail number rather than name was changed | Auto-fix stale boats (§2B.3 step 4). |
Boat predates a newly-added alias entry (e.g. YC868-peachteatsvelocity-... created before the peachteatsvelocity → velocity alias) |
The boat was imported with the (now-alias) raw name before the alias entry existed | Auto-fix stale boats (§2B.3 step 4). |
Boat predates a newly-added design override (e.g. MYC12-santoy-radford12 exists, override added pointing to radford12catrig) |
Design override added after the boat was already created with the raw design | Design override correction (§2B.3 step 3). |
noclub entry not applied to a boat created before the entry, or to a post-migration boatId |
noclub keys by full boatId; if the boatId was just rewritten by a design migration, the pre-migration noclub correction missed it |
noclub correction (post-migration) (§2B.3 step 8). |
Boat name still carries a decorative suffix (e.g. "Foobar - GM", boatId …-foobargm) after the suffix list was extended |
The boat predates the addition of that suffix to STANDARD_SUFFIXES (§1.2) |
Suffix-strip normalisation (§2B.3 step 6). The original suffixed name is absorbed into aliases.yaml. |
Two boats share the same sail+design but differ only in name suffix/prefix ("Sticky" and "Sticky II", "Goat" and "The Goat") |
Both records predate the addition of name-equivalence matching, or were created by parallel imports before Phase 1.5 was wired in |
Name-equivalence collapse (§2B.3 step 7). Canonical chosen by preferredDisplayName. |
Prior to the 2026-05 cleanup, the Auto-fix stale boats pass had a bug
(Aliases.lookupBoat was called with normName + "-" + designId instead of
just normName), causing it to silently skip every boat that had a designId.
The pass was effectively a no-op for the live dataset, so duplicates accumulated
across years of imports / admin operations. The fix at DataStore.java:2130
restores the safety net.