Scrydon
Authoring: Ontologies

Proximity link types

Declare spatial (and spatio-temporal) relationships between two coordinate-bearing object types — "this aircraft was within 50 km of this military base at the time of observation" — as first-class, governed, queryable link types in your ontology pack.

This page covers proximity link types — a new edge-inference strategy added alongside the existing value-join ("reference") strategy. It extends the Ontologies authoring guide. Read that guide first if you are new to ontology packs.

Standard link types in an ontology pack infer edges by value-join — a source object's property must equal a target object's identity column (FK semantics). This works well for Payment → Counterparty or Aircraft → AircraftType where a shared key exists.

Many real-world relationships are spatial or spatio-temporal, not key-equality:

  • An aircraft was near a military base (no shared ID).
  • A drone observation was near an aircraft at the same time.
  • A submarine cable landing point is near a monitored area.

These cannot be modelled as value-joins. A kind: "proximity" link type infers edges by haversine distance (and optionally a time window), so these relationships become first-class, governed, queryable edges — visible in the instance graph, accessible from the map, chat, and the SDK.

Reference vs proximity at a glance

"reference" (default)"proximity"
Edge inferenceValue-join on a key columnHaversine distance (+ optional time window)
Required fieldmaxMeters
Optional fieldmaxMinutes, six *LatProperty/*LngProperty/*TimeProperty overrides
CardinalityDeclared per linkImplicitly N-N (any number of targets in range)
Existing packsUnchanged — kind defaults to "reference"

Add kind: "proximity" and maxMeters to a defineLinkType(...) entry inside defineOntology({ linkTypes: [...] }):

import { defineOntology, defineLinkType } from '@scrydon/sdk-authoring/ontologies'

export default defineOntology({
  id: 'my-osint-pack',
  version: '1.0.0',
  displayName: 'My OSINT Pack',
  description: 'Aircraft, bases, and cable infrastructure.',
  maturity: 'preview',
  author: { name: 'Acme Intelligence' },

  objectTypes: [/* ... Aircraft, MilitaryBase ... */],

  linkTypes: [
    defineLinkType({
      slug: 'AircraftNearBase',
      displayName: 'Aircraft near base',
      sourceTypes: ['Aircraft'],
      targetTypes: ['MilitaryBase'],
      kind: 'proximity',   // NEW — edge inferred by haversine distance
      maxMeters: 50000,    // 50 km threshold (required for proximity)
    }),
  ],

  bindings: [/* ... */],
})

The platform computes edges at query time over the governed projection of Aircraft and MilitaryBase instances. No new read path, no raw DB queries — the same DLP/RLS/clearance rules that apply to map and chat already govern which nodes are loaded.


Schema reference

FieldTypeRequiredNotes
slugSlugYesUnique identifier, e.g. "AircraftNearBase".
displayNamestringYesHuman-readable edge label shown on the graph.
descriptionstringNoOptional longer description.
sourceTypesstring[]YesObject Type slugs on the source side.
targetTypesstring[]YesObject Type slugs on the target side.
kind"proximity"YesMust be "proximity" to enable spatial inference.
maxMetersnumberYesSpatial threshold in metres, must be > 0. An edge is only created when haversineDistance(source, target) ≤ maxMeters.
maxMinutesnumberNoTemporal window in minutes. Applies only when both the source and target instance expose a timestamp. Omit for purely spatial relationships.
sourceLatPropertystringNoOverride the source latitude column name.
sourceLngPropertystringNoOverride the source longitude column name.
sourceTimePropertystringNoOverride the source timestamp column name.
targetLatPropertystringNoOverride the target latitude column name.
targetLngPropertystringNoOverride the target longitude column name.
targetTimePropertystringNoOverride the target timestamp column name.

Coordinate convention

The platform looks for coordinates on each instance's properties using a convention ladder. You only need the six override fields when your object type uses non-standard column names.

Latitude — tried in order: latitude, lat

Longitude — tried in order: longitude, lng, lon

Timestamp — tried in order: observedAt, seenAt, timestamp, time, dateUtc, sentAt; then any property whose name ends in At or time (case-insensitive). If no timestamp is found, the instance is treated as a static point (time window is ignored for that side).

Valid range check: latitude must be in [-90, 90] and longitude in [-180, 180]. Instances with out-of-range or non-numeric coordinate values are skipped silently — they do not produce proximity edges.

When to use coordinate overrides

Use the per-side override fields when a type's coordinate columns don't follow the convention. For example, a SubmarineCable type might store its landing-point coordinates as anchorLatitude/anchorLongitude:

defineLinkType({
  slug: 'AircraftNearCable',
  displayName: 'Aircraft near cable landing',
  sourceTypes: ['Aircraft'],
  targetTypes: ['SubmarineCable'],
  kind: 'proximity',
  maxMeters: 20000,
  // Aircraft uses standard lat/lng — no source override needed.
  // SubmarineCable uses non-standard column names — override the target side:
  targetLatProperty: 'anchorLatitude',
  targetLngProperty: 'anchorLongitude',
})

The Aircraft side resolves coordinates from latitude/longitude by convention; the SubmarineCable side resolves them from anchorLatitude/anchorLongitude explicitly.


Spatial-only vs spatio-temporal

Spatial-only (no maxMinutes): an edge is created whenever source and target are within maxMeters. The types' timestamp properties are irrelevant.

defineLinkType({
  slug: 'BaseNearCable',
  displayName: 'Base near cable landing',
  sourceTypes: ['MilitaryBase'],
  targetTypes: ['SubmarineCable'],
  kind: 'proximity',
  maxMeters: 30000,  // purely spatial — no time window
  targetLatProperty: 'anchorLatitude',
  targetLngProperty: 'anchorLongitude',
})

Spatio-temporal (maxMinutes set): an edge is created when source and target are within maxMeters and both sides expose a timestamp and those timestamps are within maxMinutes of each other. If either side has no resolvable timestamp the time window is silently skipped and the edge is decided on distance alone.

defineLinkType({
  slug: 'DroneObservationNearAircraft',
  displayName: 'Drone observation near aircraft',
  sourceTypes: ['DroneObservation'],
  targetTypes: ['Aircraft'],
  kind: 'proximity',
  maxMeters: 25000,
  maxMinutes: 30,    // both sides carry timestamps — time window applies
})

DroneObservation carries observedAt and Aircraft carries seenAt — both resolve by the convention ladder, so no overrides are needed.


Complete example — AircraftNearBase

A full ontology pack with two object types and one proximity link:

import {
  defineOntology,
  defineObjectType,
  defineProperty,
  defineLinkType,
  defineIdentityRule,
  defineBinding,
} from '@scrydon/sdk-authoring/ontologies'

export default defineOntology({
  id: 'military-situational-awareness',
  version: '1.0.0',
  displayName: 'Military Situational Awareness',
  description: 'Aircraft positions and military bases — connected by proximity.',
  maturity: 'preview',
  author: { name: 'Acme Intelligence Team' },

  objectTypes: [
    defineObjectType({
      slug: 'Aircraft',
      displayName: 'Aircraft',
      kind: 'analytical',
      properties: [
        defineProperty({ slug: 'callsign',  displayName: 'Callsign',   scalarType: 'string', cardinality: 'required' }),
        defineProperty({ slug: 'latitude',  displayName: 'Latitude',   scalarType: 'float',  cardinality: 'required' }),
        defineProperty({ slug: 'longitude', displayName: 'Longitude',  scalarType: 'float',  cardinality: 'required' }),
        defineProperty({ slug: 'altitude',  displayName: 'Altitude (m)', scalarType: 'float' }),
        defineProperty({ slug: 'seenAt',    displayName: 'Seen at',    scalarType: 'timestamp' }),
      ],
    }),

    defineObjectType({
      slug: 'MilitaryBase',
      displayName: 'Military Base',
      kind: 'analytical',
      properties: [
        defineProperty({ slug: 'name',      displayName: 'Name',       scalarType: 'string', cardinality: 'required' }),
        defineProperty({ slug: 'country',   displayName: 'Country',    scalarType: 'string' }),
        defineProperty({ slug: 'latitude',  displayName: 'Latitude',   scalarType: 'float',  cardinality: 'required' }),
        defineProperty({ slug: 'longitude', displayName: 'Longitude',  scalarType: 'float',  cardinality: 'required' }),
      ],
    }),
  ],

  linkTypes: [
    defineLinkType({
      slug: 'AircraftNearBase',
      displayName: 'Aircraft near base',
      description: 'Aircraft within 50 km of a military base.',
      sourceTypes: ['Aircraft'],
      targetTypes: ['MilitaryBase'],
      kind: 'proximity',
      maxMeters: 50000,   // 50 km
      // Both types use standard latitude/longitude column names — no overrides needed.
      // MilitaryBase has no timestamp — purely spatial.
    }),
  ],

  identityRules: [
    defineIdentityRule({ objectTypeSlug: 'Aircraft',      deterministicKeys: ['callsign'] }),
    defineIdentityRule({ objectTypeSlug: 'MilitaryBase',  deterministicKeys: ['name'] }),
  ],

  bindings: [
    defineBinding({
      objectTypeSlug: 'Aircraft',
      kind: 'silver_table',
      source: { table: 'silver_aircraft_positions' },
      identitySpec: { keys: ['callsign'] },
      columnMap: {
        callsign:  'callsign',
        latitude:  'lat',
        longitude: 'lon',
        altitude:  'altitude_m',
        seenAt:    'seen_at',
      },
    }),
    defineBinding({
      objectTypeSlug: 'MilitaryBase',
      kind: 'silver_table',
      source: { table: 'silver_military_bases' },
      identitySpec: { keys: ['name'] },
      columnMap: {
        name:      'base_name',
        country:   'country_code',
        latitude:  'lat',
        longitude: 'lon',
      },
    }),
  ],
})

Once this pack is installed, the instance graph draws edges labelled "Aircraft near base" between any aircraft and any base within 50 km. Because MilitaryBase has no timestamp property, the relationship is purely spatial — distance is the only criterion.


Graph view radius override

On the Analytics graph canvas you can drag a radius slider under the Display panel to try different thresholds without editing the pack. The slider sends an optional proximityOverride to the instance-graph endpoint that overrides maxMeters (and optionally maxMinutes) for the current view session only — the pack's declared defaults are unchanged.

This lets you answer "show me everything within 200 km" with one drag, rather than re-authoring the pack.

The radius override is global for the current view session — it applies to all proximity links in the ontology at once. Per-link tuning is a planned follow-up.


Troubleshooting

No proximity edges appear

  1. Neither side has resolvable coordinates. Check that both the source and target object types have latitude and longitude properties with values in the expected columns. The convention ladder (latitude/lat, longitude/lng/lon) must match a column that contains a numeric value in [-90, 90] × [-180, 180]. Use targetLatProperty/targetLngProperty overrides when the columns have non-standard names.

  2. The threshold is too small for the actual data. Try temporarily increasing maxMeters (or use the radius slider in the graph view) to confirm data is present. A 50 km threshold works for aircraft-to-base; cable infrastructure spread over ocean basins may need 200 km or more.

  3. Object types are not bound. Proximity inference only runs over instances that are loaded into the graph — which requires the object type to have a working binding. Check that your defineBinding(...) entries are present and that the upstream silver table contains rows.

  4. Only one side has coordinates. If the source type has no resolvable coordinates, it contributes no source points and no edges are emitted. Both sides must have coordinates.

Spatio-temporal: time window is not filtering as expected

The time window (maxMinutes) is applied only when both the source and target instance expose a resolvable timestamp. If one side has no timestamp, the time criterion is silently skipped and the edge is decided on distance alone. This is by design — a static object (e.g., a MilitaryBase with no timestamp) is always a valid proximity target regardless of when a source was observed.

If you want strict time-gating on both sides, verify that both types have a timestamp property following the convention (observedAt, seenAt, timestamp, time, dateUtc, sentAt, or any property ending in At/time). Use sourceTimeProperty/targetTimeProperty overrides when the columns have non-standard names.


Where to next

On this page

On this page