Repository navigation
Object Model Architecture
There was a previous Java prototype in the repository. It was a deliberate quick-and-dirty exploratory prototype — written to understand the domain, not as a production design. The prototype code has been deleted; only the domain knowledge it embodied influenced the new design. The specific design problems it exhibited are listed below as guidance on what to avoid.
Classes like Boat use a static ArrayList as a global singleton registry (e.g. __byId). This makes it impossible to run multiple optimisation scopes simultaneously, breaks unit testing, and creates hidden shared state. Do not replicate this pattern.
Mutable derived fields (computed handicaps, race counts, running averages) sit alongside immutable raw fields in the same class. For example, Boat holds both the raw _name/_sailNumber (final) and mutable _spinnakerHC/_racedSpinnaker (derived). These have fundamentally different lifecycles and must be separated.
Boat holds a List<Entry> — a direct back-reference from parent to child. This creates circular object graphs that cause problems for serialisation (Jackson infinite recursion), testing, and reasoning about object ownership. Navigation from parent to children must go through index objects, not fields on the parent.
The add() method on Boat contains MYC-specific logic for tracking the latest handicap date. Club-specific concerns must not live in a generic domain object.
addRaced() computes a running average handicap inline on Boat. Analysis and optimisation logic belongs in dedicated service classes, not in the entities being analysed.
saveHC() / restoreHC() exist to support optimisation iterations. This is a symptom of mutable derived state living on the raw object. In the new design, optimisation state lives in dedicated mutable working objects; the raw layer is never mutated.
The architecture has four layers with a strict one-way dependency:
Optimised derived → Deterministic derived → Raw
Index → Raw
Raw → (nothing in this project)
The raw layer captures data exactly as ingested from source systems. It is persisted as JSON files (one per top-level entity). Once stored, raw data is never mutated — updates create a new record with updated fields.
- All raw layer entities are Java records — immutability, compact syntax, and value semantics.
- Raw records hold no back-references and no derived fields.
- Raw records know nothing about any other layer.
- Top-level entities implement the
Loadableinterface for dirty-tracking vialoadedAt()timestamp.
Top-level records (one JSON file each):
-
Boat— id, sailNumber, name, designId, clubId, aliases (List<TimedAlias>), altSailNumbers, certificates (List<Certificate>), sources, lastUpdated -
Race— id, clubId, seriesIds, date, number, name, divisions (List<Division>), source, lastUpdated -
Club— id, shortName, longName, state, aliases, topyachtUrls, series (List<Series>) -
Design— id, canonicalName, makerIds, aliases, sources, lastUpdated
Embedded records (nested inside their parent, not persisted separately):
-
Certificate— system (IRC/ORC/AMS), year, value (TCF), nonSpinnaker, twoHanded, club, windwardLeeward, certificateNumber, expiryDate. Embedded inBoat. -
Division— name, finishers (List<Finisher>). Embedded inRace. -
Finisher— boatId, elapsedTime (Duration), nonSpinnaker, certificateNumber. Embedded inDivision. -
Series— id, name, isCatchAll, raceIds. Embedded inClub. -
TimedAlias— name. Embedded inBoat. -
Maker— id, canonicalName, aliases. Stored incatalogue/makers.json.
Supporting records:
-
Factor— value (double) + weight (double). Used throughout the analysis layer; provides static methodsapply(),compose(),aggregate(). -
WeightedInterval— duration + weight. Used in race analysis.
The deterministic derived layer is computed from the raw layer by functions that are deterministic given the same raw data and configuration parameters. Results are held in volatile fields in AnalysisCache — not persisted to disk, but recomputed on startup and after each importer run.
- All derived types are Java records — immutable once computed.
- Recomputation is triggered by
AnalysisCache.refresh()after importer runs or configuration changes.
Comparison analysis (produced by HandicapAnalyser):
-
ComparisonResult— key (ComparisonKey), pairs (DataPair[]), trimmedPairs (outlier-removed), fit (LinearFit) -
ComparisonKey— identifies a handicap comparison (system A/B, variant A/B, year A/B); factory methods for all comparison types -
DataPair— boatId, x (TCF), y (TCF) -
LinearFit— slope, intercept, r², se, n, xMean, ssx; methods: predict(x), inverse(), weight(x)
Conversion graph (produced from ComparisonResults):
-
ConversionGraph— directed graph of system×year×variant nodes connected by LinearFit edges (minimum R² = 0.75) -
ConversionNode— system, year, nonSpinnaker, twoHanded -
ConversionEdge— from node, to node, fit
Reference factors (produced by ReferenceNetworkBuilder via DFS of the ConversionGraph):
-
BoatReferenceFactors— spin (Factor), nonSpin (Factor), twoHanded (Factor), with generation tracking per variant -
Factor— value + weight pair with static composition methods
These are the anchors for the future PF optimisation layer.
Navigation that would require back-references on raw records (e.g. "all races for a boat") is handled by index maps in AnalysisCache rather than collection fields on parent entities. Indexes are derived deterministically from raw data and rebuilt via AnalysisCache.refreshIndexes().
// Rather than: boat.getEntries() ← do not do this
// Use: cache.raceIdsByBoatId().get(boat.id())Current indexes (volatile Map fields in AnalysisCache):
-
boatIdsByDesignId— designId → Set of boatIds -
raceIdsByBoatId— boatId → Set of raceIds -
seriesIdsByBoatId— boatId → Set of seriesIds
The optimised derived layer will be produced by the PF optimisation (pipeline Steps 13–19). It will be non-deterministic in the sense that its output depends on configuration (scope, λ, convergence threshold). It will never be persisted — always recomputed on demand.
- Use ordinary mutable classes for working objects updated during alternating least squares iterations.
- Use records for final output snapshots once optimisation converges.
- Working objects hold references to raw records and deterministic derived records as fixed inputs; they never mutate those inputs.
| Layer | Persisted? | Condition |
|---|---|---|
| Raw | Always (JSON files) | Source of truth |
| Deterministic derived | No (held in AnalysisCache) | Recomputed on startup and after imports |
| Index | No (held in AnalysisCache) | Recomputed on demand |
| Optimised derived | Never | Not yet implemented |
hpf-data/ (resolved via PF_DATA env, ./hpf-data, or ~/.hpf-data)
boats/{boatId}.json
designs/{designId}.json
clubs/{clubId}.json
races/{clubId}/{seriesSlug}/{raceId}.json
catalogue/makers.json
config/
admin.yaml (importer schedule, thresholds, configurable weights)
aliases.yaml (read-only seed: boat/design aliases, sail number redirects)
clubs.yaml (read-only seed: club metadata, TopYacht URLs)
design.yaml (read-only seed: exclusions, design overrides)
exclusions.json (mutable: excluded boat/design/race IDs, managed by admin UI)
Analysis and optimisation logic lives in dedicated service classes, not in entity objects:
Importers (populate Layer 1):
-
SailSysBoatImporter— imports boats and IRC certificates from SailSys boat API/files -
SailSysRaceImporter— imports races, divisions, finishers from SailSys race API/files -
TopYachtImporter— scrapes TopYacht HTML; handles multi-page merge, caption-based variant detection -
OrcImporter— fetches ORC certificate feed XML; creates certificates with variant flags -
AmsImporter— scrapes AMS certificate listings from raceyachts.org -
BwpsImporter— imports BWPS (CYCA) race results
Analysis (Layers 2–3):
-
HandicapAnalyser— mines paired observations across boats to produceComparisonResultwithLinearFit -
ConversionGraph— directed graph of system×year×variant conversions -
ReferenceNetworkBuilder— DFS traversal of ConversionGraph to computeBoatReferenceFactors(Steps 8–12) -
AnalysisCache— holds all derived data in volatile fields; providesrefresh(),refreshReferenceFactors(),refreshIndexes()
Server:
-
HpfServer— Jetty 12.1 embedded server entry point (port 8080) -
ImporterService— manages importer lifecycle, background execution, scheduling, and admin configuration (fromadmin.yaml) -
AdminApiServlet— REST API for boats/designs/races/importers/stats (paginated, with search/filter) -
AnalysisServlet— REST API for comparison analysis results -
StaticResourceServlet— serves admin frontend HTML/JS/CSS from classpath resources
Store:
-
DataStore— central persistence; reads/writes JSON files; manages in-memory maps; Jaro-Winkler fuzzy matching for boat/design names -
AliasSeedLoader— readsaliases.yamlfor boat/design name aliases and sail number redirects -
ClubSeedLoader— readsclubs.yamlfor club metadata and TopYacht URLs -
DesignCatalogueLoader— readsdesign.yamlfor exclusions and design overrides -
IdGenerator— pure normalisation utilities for generating entity IDs -
RemoveNonMeasurementCertificates— housekeeping utility to strip PHS/CBH certificates
Entity records and classes hold data. Services hold behaviour.