Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.axiomancer.io/llms.txt

Use this file to discover all available pages before exploring further.

Axiom Overwatch derives vessel-to-vessel encounters from raw AIS positions and emits per-pair, per-timestep geometry — closest point of approach (CPA), time to CPA (TCPA), range, closing speed, and bearing rate. These records are the foundation Overwatch uses for collision-risk surfacing, ship-to-ship rendezvous detection, and forensic incident reconstruction.

When to use it

Reach for encounter data when you need to answer questions about how two vessels behaved relative to each other, not how one vessel behaved at a point in time. Typical use cases:
  • Reviewing near-miss geometry around a port approach or a traffic separation scheme
  • Reconstructing the timeline of a collision, allision, or grounding for a forensics report
  • Triggering downstream risk scoring (COLREGS, kinematic causality)
  • Filtering for candidate ship-to-ship transfer windows before deeper investigation
If you only need single-vessel state — speed, heading, draft, port arrivals — use vessel positions instead.

How encounters are detected

For every pair of vessels in a spatial/temporal window, Overwatch streams their AIS positions through a hysteresis trigger. An encounter opens when the geometry crosses the entry threshold and closes after the exit threshold has held for a sustained window.
1

Stream-merge per pair

For each unordered vessel pair, positions from both vessels are merged in time order. Every input timestamp triggers re-evaluation against the most recent fix from the other vessel — there is no interpolation between AIS reports, so observed geometry is always grounded in real broadcasts.
2

Geometry per epoch

At each evaluation tick, Overwatch computes range (nautical miles), relative bearings from each vessel, course difference, closing speed (knots), TCPA (seconds), DCPA (nautical miles), bearing rate (degrees per minute), and the pass side from each vessel’s perspective.
3

Hysteresis trigger

A deterministic risk probability risk_prob is derived from range, TCPA, and DCPA factors. The encounter opens when risk_prob rises above the entry threshold and the geometric gates (range, TCPA, DCPA) are simultaneously inside their limits. It closes only after risk_prob has stayed below the exit threshold for the configured hysteresis window.
4

Canonical pair ordering

Each unordered vessel pair is stored once, with vessel_a_id < vessel_b_id (lexicographic on IMO). This guarantees a single canonical row per encounter and avoids double-counting when querying by either vessel.

Default thresholds

ParameterDefaultMeaning
range_max_nm6.0Maximum range, in nautical miles, for the geometry gate
tcpa_max_s1800Maximum TCPA, in seconds (30 minutes)
dcpa_max_nm1.5Maximum projected DCPA, in nautical miles
p_risk_enter0.35risk_prob required to open an encounter
p_risk_exit0.20risk_prob required (sustained) to close one
hysteresis_s300Seconds the exit condition must hold (5 minutes)
pair_max_age_s600Discard pairs whose latest fixes are more than 10 minutes apart
These defaults work for open-water and approach geometry. Restricted waters or chokepoint contexts may justify tighter values; pass overrides at extraction time.

Output shape

Two records are produced per detected encounter. Encounter (one row per detected window):
  • vessel_a_id, vessel_b_id — canonical IMO ordering, vessel_a_id < vessel_b_id
  • start_ts, end_ts, cpa_ts — encounter window and the timestamp of minimum range
  • min_range_nm, min_dcpa_nm, min_tcpa_s — geometry minima across the window
  • risk_window_start_ts — the first epoch where risk_prob crossed the entry threshold
  • observed_fraction — fraction of epochs grounded in direct AIS observation (always 1.0 today; reserved for future imputation)
  • encounter_conf — mean per-epoch geometry confidence
  • context_tag — environmental context (open_sea, channel, tss, approach, anchorage); populated by a downstream classifier
  • max_course_change_a_deg, max_course_change_b_deg — largest single-step COG delta observed for each vessel across the encounter, in degrees
  • rule17_deviation_a, rule17_deviation_b — boolean flags fired when the corresponding vessel’s mean stand-on probability across the encounter clears a threshold AND its max course change is large enough to count as a meaningful maneuver (see Rule 17 deviation detection)
  • rule17_handoff_ts, rule17_handoff_trigger — the timestamp at which Rule 17(a) “keep course and speed” authority transitioned into Rule 17(b)/(c) “may / must take avoiding action,” and which gate fired (giveway_inaction or extremis). Both are NULL when no handoff condition tripped (see Rule 17 handoff timestamp)
Encounter epoch (one row per timestep, keyed by encounter_id + ts_utc):
  • range_nm, rel_bearing_a_deg, rel_bearing_b_deg, course_diff_deg
  • closing_speed_kt, tcpa_s, dcpa_nm, bearing_rate_deg_min
  • pass_side_a, pass_side_b-1 (port), 0 (ahead/astern), 1 (starboard)
  • risk_prob — deterministic geometry-only risk in [0, 1]
  • p_head_on, p_overtaking, p_crossing — COLREGS rule posteriors derived from epoch geometry; sum to 1
  • p_special_context — reserved for TSS / narrow-channel / RAM context; left NULL until spatial context ingestion lands
  • give_way_prob_a, stand_on_prob_a, give_way_prob_b, stand_on_prob_b — per-vessel role posteriors conditioned on the rule posteriors

Configuring an extraction

The extractor is exposed from @axiom/core for use in workers and Edge Functions. Pass a pre-filtered slice of AIS positions for a tractable spatial/temporal window — the multi-vessel form is O(n²) in the number of unique IMOs.
import { extractEncounters } from "@axiom/core/processing/encounter-extraction"

const encounters = extractEncounters(positions, {
  rangeMaxNm:  3.0,    // tighter than default for a port approach
  tcpaMaxS:    1200,
  dcpaMaxNm:   1.0,
  pRiskEnter:  0.40,
  pRiskExit:   0.20,
  hysteresisS: 300,
  pairMaxAgeS: 600,
})

for (const enc of encounters) {
  console.log(
    enc.vesselAId, "x", enc.vesselBId,
    "min range", enc.minRangeNm.toFixed(2), "nm",
    "min DCPA", enc.minDcpaNm.toFixed(2), "nm",
    "epochs", enc.epochs.length,
  )
}
For a single known pair, use extractEncountersForPair(vesselA, vesselB, posA, posB, config)vesselA must sort lexicographically before vesselB.

Production pipeline

In production, the extractor runs as a scheduled Edge Function rather than ad-hoc against @axiom/core. The function reads recent AIS positions, generates candidate vessel pairs with H3 spatial indexing, runs the same algorithm above, and persists results. Output is the source of truth for the Risk API, the Investigations API, and any downstream COLREGS / kinematic-causality pipelines.

Hourly schedule

The extract-encounters-hourly cron job invokes the Edge Function at minute :07 of every hour. Each run pulls the trailing 90 minutes of AIS positions — a 30-minute overlap with the prior run guarantees encounters that straddle the hour boundary are seen end-to-end. Results are persisted with two upserts:
  • pairwise_encounter keyed on (vessel_a_id, vessel_b_id, start_ts)
  • encounter_epoch keyed on (encounter_id, ts_utc)
Because both keys are stable across runs, re-invoking the function over the same window is idempotent — a re-run updates the existing rows instead of inserting duplicates.

Spatial pair generation

Naively pairing every vessel with every other vessel is O(n²) in the active fleet, which is intractable at production fleet size. Instead, each AIS position is bucketed into its H3 resolution-6 parent cell (~12 km edge, comparable to the 6 NM range gate). Within each cell, candidate pairs are generated as {IMOs in cell} × {IMOs in cell ∪ 1-ring neighbour cells}. The 1-ring expansion catches pairs that straddle a cell boundary; a canonical pair-key dedupe stops the same pair from being scored twice when both vessels are in the same cell. Each run is bounded by a hard cap of 50,000 candidate pairs. If a window exceeds this, the function aborts with a pair_explosion error rather than running away — a guard against bad input data or pathological clustering.

Direct invocation

You can call the Edge Function directly to re-process a specific window — for example, after correcting upstream AIS data or for a forensic re-pull. Pass since and until as ISO 8601 timestamps; both are required when overriding the default 90-minute trailing window.
curl -X POST "$SUPABASE_URL/functions/v1/extract-encounters" \
  -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "since": "2026-04-15T00:00:00Z",
    "until": "2026-04-15T01:00:00Z"
  }'
The response includes counts for the run:
{
  "ok": true,
  "positions": 18234,
  "pairs_evaluated": 1842,
  "encounters": 17,
  "epochs": 412,
  "since": "2026-04-15T00:00:00Z",
  "until": "2026-04-15T01:00:00Z"
}
Each run also writes a row to ingestion_logs with these counts and the window, so production runs are traceable from observability tooling.

Backfilling history

For multi-day re-processing — onboarding a new region, replaying after an algorithm change, or filling a gap — use the backfill-encounters.mjs driver. It chunks a since → until range into fixed-length windows and POSTs each one to the Edge Function in sequence:
# Last 7 days, 60-minute windows (default)
node scripts/backfill-encounters.mjs --days 7

# Explicit window
node scripts/backfill-encounters.mjs \
  --since 2026-04-01T00:00Z \
  --until 2026-04-08T00:00Z \
  --window-minutes 60

# Plan-only — print chunk count and exit without calling
node scripts/backfill-encounters.mjs --days 1 --dry-run
The driver requires SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in the environment (or --supabase-url / --service-key flags). Because the Edge Function upserts on stable keys, re-running a botched chunk in place is safe — there is no need to drop rows before retrying. As a runtime estimate: a 7-day backfill at 60-minute windows is 168 chunks; with the default 2-second sleep between chunks plus per-chunk Edge Function elapsed time, the full run takes roughly 10–20 minutes depending on position density.

Rule and role posterior inference

Every encounter epoch is annotated with COLREGS-aligned rule posteriors (p_head_on, p_overtaking, p_crossing) and per-vessel role posteriors (give_way_prob_a, stand_on_prob_a, give_way_prob_b, stand_on_prob_b). The rule posteriors are normalized to sum to 1 and are derived from epoch geometry alone — no calibrated model, no priors from prior epochs, and no environmental context. This is enough to surface meaningful rule and role probabilities for screening, timeline reconstruction, and downstream COLREGS adjudication, and it lets consumers stop carrying NULL-handling branches on these columns.

Rule posteriors

The three rule posteriors are produced by a smooth-step decision rule over course difference and the relative bearing each vessel sees of the other:
RuleGeometric gate
Head-on (Rule 14)Reciprocal courses (course_diff_deg ≥ 175°) and both vessels see the other near dead ahead (`rel_bearing_*_deg≤ 7°`)
Overtaking (Rule 13)Near-parallel courses (course_diff_deg ≤ 67.5°) and one vessel sees the other forward of the beam while the other sees its counterpart more than 22.5° abaft the beam (`rel_bearing_*_deg> 112.5°`)
Crossing (Rule 15)Residual: 1 − p_head_on − p_overtaking
Smooth thresholds rather than hard cutoffs mean a clean head-on (Δc ≥ 178°, |θ| ≤ 2°) drives p_head_on ≥ 0.9, a clean overtaking and a clean crossing each cross 0.85, and ambiguous geometry produces a soft mixture instead of a brittle vote.

Role posteriors

Role posteriors are conditioned on the rule posteriors:
  • Head-on (Rule 14): both vessels are required to alter course to starboard — neither has stand-on priority. give_way_prob_* and stand_on_prob_* each contribute 0.5 of the head-on mass to both vessels.
  • Overtaking (Rule 13): the vessel that sees the other more forward (smaller |rel_bearing|) is the overtaker and is assigned the give-way mass; the overtaken vessel takes the stand-on mass.
  • Crossing (Rule 15): the vessel with the other on its starboard side is give-way. Concretely, pass_side_a = +1 means B is on A’s starboard, so A is give-way; −1 is the mirror; 0 (other vessel dead ahead/astern) splits the crossing mass 50/50.

What the posteriors do not capture

  • No special-context axis yet. p_special_context (TSS, narrow channel, RAM, tug-tow) requires TSS polygon ingestion and a vessels.nav_status join that are not yet wired in. The column ships NULL and will populate without a schema change when that lands.
  • No prior smoothing. Each epoch is scored independently. A pair that flips between crossing and overtaking geometry near the threshold will see the posteriors flip with it; downstream consumers that need a single rule label per encounter should aggregate across the encounter window (e.g., the rule with the highest mean posterior over the risk window).
  • No nav-status priors. A vessel not_under_command or restricted_in_ability_to_manoeuvre is treated identically to one underway and free to manoeuvre.
The numeric columns above are sufficient for current consumers — the Risk API and Investigations API read them directly, and any downstream COLREGS adjudication can layer a calibrated model on top without touching the schema.

Rule 17 deviation detection

Per COLREGS Rule 17(a)(i), the stand-on vessel shall keep her course and speed. Rule 17(a)(ii) and Rule 17(b) only authorise — and ultimately require — the stand-on vessel to manoeuvre when the give-way vessel is clearly failing to keep clear. A meaningful course alteration by a vessel with high stand-on probability is therefore unusual on its face: it typically indicates that the give-way side did not act in time and the stand-on side was forced to break stand-on to avert collision. The deviation itself is an evidence signal worth surfacing.

When to use it

Reach for the Rule 17 deviation flags when you need to triage which encounters to read in detail rather than scan all of them. Typical use cases:
  • Filtering for encounters where stand-on action was forced — i.e. likely give-way non-compliance
  • Producing analyst worklists that lead with the encounters most likely to warrant manual COLREGS adjudication
  • Annotating forensic timelines so investigators see the stand-on side’s late maneuver as a first-class event, not a buried column on the epoch table
The flags are deterministic and geometry-only — they don’t replace COLREGS adjudication, they prioritise which encounters to send to it.

How the flags are computed

Each pairwise_encounter row carries two new pairs of columns: max_course_change_{a,b}_deg and rule17_deviation_{a,b}.
  • max_course_change_*_deg — largest single-step course-over-ground delta observed for that vessel across the encounter epochs. Computed by walking the per-tick AIS COG snapshots stored on each epoch and taking the maximum wrapped delta between adjacent ticks.
  • rule17_deviation_*TRUE when both gates hold for that vessel, FALSE otherwise:
    • max_course_change_*_deg ≥ 10° — the maneuver was meaningful (small AIS-jitter wobble doesn’t fire)
    • mean(stand_on_prob_*) across the encounter epochs ≥ 0.6 — the vessel was the stand-on side often enough that staying on course was the legal expectation
Both gates are required: a 30° turn from a give-way vessel is what Rule 17 expects (give-way is supposed to maneuver), so the flag stays FALSE there. A drifting stand-on vessel that wobbles 5° fires neither gate. The combination of “high mean stand-on probability AND a real course change” is what makes the boolean specifically a Rule 17 signal rather than a generic “vessel turned” indicator. The thresholds are deliberately tuned conservatively for Phase 1 — false positives in either direction here propagate to analyst worklists, so the defaults err on the side of only firing when both conditions are clearly met. They’re exported from @axiom/core/processing/encounter-extraction as RULE17_MIN_COURSE_CHANGE_DEG and RULE17_MIN_MEAN_STAND_ON_PROB so analyst tooling can probe with non-defaults without forking the algorithm.

Querying for fired deviations

The columns are indexed for the common analyst query “show me encounters where a stand-on vessel was forced into action recently”:
SELECT
  vessel_a_id, vessel_b_id, start_ts, cpa_ts,
  min_dcpa_nm,
  max_course_change_a_deg, rule17_deviation_a,
  max_course_change_b_deg, rule17_deviation_b
FROM pairwise_encounter
WHERE rule17_deviation_a OR rule17_deviation_b
ORDER BY start_ts DESC
LIMIT 100;
A partial index over start_ts DESC with the rule17_deviation_a OR rule17_deviation_b predicate keeps the worklist query cheap even at production fleet size.

What the flags do not capture

Rule 17 deviation detection is the first phase of a broader compliance-evidence pipeline. The Phase 1 flags ship from pure geometry and AIS COG; richer signals are deliberately deferred to follow-up phases:
  • No counterfactual compliance distance. The flag answers “did the stand-on vessel maneuver?” not “how much did the give-way vessel’s path differ from a compliant one?”. Counterfactual compliance distance requires a maneuver-prediction model and is a separate ticket.
  • No ghost-encounter inference. Encounters that would have happened if the stand-on vessel had not maneuvered (i.e. the near-miss the deviation prevented) are not back-projected onto the encounter table. That requires the same prediction model as the counterfactual distance and lands in the same follow-up.
  • No nav-status priors. A vessel not_under_command or restricted_in_ability_to_manoeuvre is treated identically to one underway and free to manoeuvre — the same caveat that applies to the rule and role posteriors above.
  • No special-context awareness. Encounters inside a TSS or narrow channel can carry rule-specific overrides that change what “stand-on” means; that requires the same TSS polygon ingestion that gates p_special_context and is not yet wired in.

Rule 17 handoff timestamp

Rule 17 deviation detection answers whether a stand-on vessel was forced into a meaningful maneuver across an encounter. The handoff timestamp answers when the legal authority for that maneuver activated — the moment Rule 17(a)‘s “keep course and speed” obligation transitioned into Rule 17(b)/(c)‘s “may / must take avoiding action.” Surfacing T* separately from the deviation flag is the operational disambiguation between premature, unnecessary deviation (the stand-on vessel broke course before authority transferred) and required avoidance (the stand-on vessel was already authorised, or compelled, to act).

When to use it

Reach for the handoff timestamp when the deviation flag alone is too coarse — for example, when you need to:
  • Distinguish a stand-on vessel that maneuvered before T* (premature deviation, potentially itself a Rule 17 violation) from one that maneuvered after (required avoidance under Rule 17(b)/(c))
  • Anchor a forensic timeline on the legal authority transition rather than on the geometric CPA, which usually lags T* by several minutes
  • Compare give-way inaction encounters against extremis encounters when prioritising analyst review — extremis means the geometry collapsed regardless of give-way behaviour and typically warrants the most urgent attention
If you only need a yes/no signal that the stand-on side acted, the Rule 17 deviation flags are sufficient.

How T* is computed

For each encounter, Overwatch first picks the canonical give-way side — whichever vessel has the higher mean give_way_prob across the encounter epochs — and then walks the epochs in chronological order. The first epoch that meets either trigger fires T*; the earliest match wins.
TriggerGates
extremisdcpa_nm < 0.1 (≈200 m). Geometry has already collapsed; Rule 17(c) compels stand-on action regardless of give-way behaviour. Short-circuits even if the give-way vessel is currently maneuvering.
giveway_inactionrisk_prob > 0.70 and dcpa_nm < 0.5 and the give-way vessel’s max single-step COG change over the trailing 120-second action window is below 5°. Rule 17(b) authority for the stand-on vessel activates because the give-way vessel is observably failing to keep clear.
Both triggers are deterministic and geometry-only. The thresholds are exported from @axiom/core/processing/encounter-extraction as RULE17B_RISK_PROB_THRESHOLD, RULE17B_DCPA_THRESHOLD_NM, RULE17B_ACTION_WINDOW_S, RULE17B_GIVEWAY_ACTION_TOL_DEG, and RULE17B_EXTREMIS_DCPA_NM so analyst tooling can probe with non-default values without forking the algorithm. When neither trigger fires across the encounter, both rule17_handoff_ts and rule17_handoff_trigger ship NULL.

Querying for fired handoffs

A partial index on rule17_handoff_ts DESC keeps the common analyst worklist query — “show me encounters where Rule 17(b)/(c) authority transferred recently” — cheap at production fleet size:
SELECT
  vessel_a_id, vessel_b_id, start_ts, cpa_ts,
  rule17_handoff_ts, rule17_handoff_trigger,
  min_dcpa_nm,
  rule17_deviation_a, rule17_deviation_b
FROM pairwise_encounter
WHERE rule17_handoff_ts IS NOT NULL
ORDER BY rule17_handoff_ts DESC
LIMIT 100;
Combining the handoff timestamp with the deviation flags isolates the most analytically interesting cells:
  • rule17_handoff_ts IS NOT NULL AND rule17_deviation_* true — the stand-on vessel maneuvered after authority transferred (required avoidance).
  • rule17_handoff_ts IS NOT NULL AND both deviation flags false — authority transferred but neither vessel acted; the encounter likely closed on its own kinematics, or both vessels were dangerously passive.
  • rule17_handoff_ts IS NULL AND rule17_deviation_* true — the stand-on vessel maneuvered before any handoff trigger fired (potential premature deviation).

What the timestamp does not capture

The Phase 1 implementation emits T* and the trigger only. Several richer signals are deliberately deferred:
  • No pre/post-handoff deviation magnitudes. Phase 1 doesn’t report how much each side maneuvered before vs. after T*; that requires a maneuver-evidence accumulator the current emit-once pipeline doesn’t carry, and lands in Phase 2.
  • No handoff confidence score. The trigger is a hard boolean per epoch; there is no continuous score capturing how decisively each gate cleared. Also Phase 2.
  • No VTS or chokepoint hotspot aggregation. Per-port and per-corridor handoff-rate rollups are a Phase 2 deliverable and are not yet exposed via the API.
  • No nav-status priors. A give-way vessel not_under_command or restricted_in_ability_to_manoeuvre is treated identically to one underway and free to manoeuvre — the same caveat that applies to the rule and role posteriors.
  • No special-context awareness. TSS and narrow-channel rule overrides aren’t yet wired in; the handoff detector uses the same geometry-only inputs as the rest of the pipeline.

What the extractor doesn’t do

  • No interpolation — every epoch is grounded in a real AIS report. Long gaps between fixes show up as long inter-epoch intervals, not synthetic samples.
  • No heading-only inference — vessels missing speed or course are skipped for that epoch.
  • No environmental context — currents, traffic separation rules, and depth restrictions are not modeled in the geometry-only risk_prob.
  • No collision adjudicationrisk_prob is a screening signal for downstream review, not an authoritative collision-risk verdict.
  • Vessels API — single-vessel position and identity data feeding the extractor
  • Risk API — surfaces encounter-derived risk indicators
  • Investigations API — case files that pull encounter timelines into a forensics view