Telecom: from raw CDRs to a co-location network

The problem

Call detail records are the highest-volume, lowest-tooled workload in telecom fraud and law-enforcement investigation. A single case can mean tens of millions of rows, and the actual analytical question — "who has this number been in contact with, and who do those contacts also talk to?" — usually ends up answered in a spreadsheet or a $100k/seat proprietary tool, because turning flat CDR rows into a navigable graph is genuinely hard: it needs time-series scan, graph traversal, and identity resolution (a number can be reassigned, a SIM can be swapped) all in the same query.

The scenario

A fraud team has a suspect MSISDN linked to a SIM-swap fraud ring. They need the immediate contact network, ranked by call volume, and they need to resolve any contact number to a known identity — without exporting CSVs between three tools.

Ingest, then query the graph directly

CDR ingest and analysis are native — CdrRecord is a first-class governed type, not a staging table you build yourself.

# Ingest a CSV export (columns: caller, callee, duration_secs, timestamp_utc)
relata cdr ingest calls.csv --purpose law_enforcement
PURPOSE 'law_enforcement'
 
-- Common-contact analysis: who has this number called or been called by,
-- ranked by call volume
SELECT callee, COUNT(*) AS call_count, SUM(duration_secs) AS total_secs
FROM CdrRecord
WHERE caller = '+919876543210' OR callee = '+919876543210'
GROUP BY callee
ORDER BY call_count DESC
LIMIT 20;
 
-- Resolve a contact number to a known identity, if one exists
SELECT * FROM RESOLVE_IDENTITY('+447700900123');

Typed SDK snippet

from relata import RelataClient
 
with RelataClient(
    "http://localhost:9090",
    bearer_token="relata-dev",
    purpose="law_enforcement",
) as client:
    # Common-contact / hand-off analysis for the suspect number
    contacts = client.query(
        "SELECT callee, COUNT(*) AS call_count, SUM(duration_secs) AS total_secs "
        "FROM CdrRecord "
        "WHERE caller = '+919876543210' OR callee = '+919876543210' "
        "GROUP BY callee ORDER BY call_count DESC LIMIT 20"
    )
 
    # Build the co-location network: for each frequent contact, find
    # their own frequent contacts, and rank the resulting network by degree
    network = {}
    for row in contacts:
        second_hop = client.query(
            f"SELECT callee, COUNT(*) AS call_count FROM CdrRecord "
            f"WHERE caller = '{row['callee']}' "
            f"GROUP BY callee ORDER BY call_count DESC LIMIT 10"
        )
        network[row["callee"]] = [r["callee"] for r in second_hop]
 
    # Resolve any number in the network to a known identity
    for number in network:
        identity = client.query(f"SELECT * FROM RESOLVE_IDENTITY('{number}')")
        if identity:
            print(f"{number} -> {identity[0]['linked_entity_ids']}")

CLI shorthand for the same two-step flow, if you're working interactively:

relata cdr analyze +919876543210
relata cdr timeline +919876543210

Why this replaces the bespoke stack

  • CdrRecord is bi-temporal, not a flat import. Number portability and SIM reassignment don't corrupt history — a query against last month's data resolves identity as it stood then, via AS OF. See Bi-Temporal Model.
  • Identity resolution is a query, not a separate lookup service. RESOLVE_IDENTITY runs against the same IdentityIndex that SmartIngest built at ingest time — no batch reconciliation job between the CDR table and a subscriber master. See Identity Resolution.
  • The graph traversal and the row scan are the same engine. DEGREE() and PATHS_BETWEEN operate on the CSR graph layer derived from CdrRecord at ingest — building a co-location network is a query, not an ETL job into a separate graph database.
  • Governed by default. PURPOSE 'law_enforcement' scopes and audits the query the same way it would for any other protected dataset — there's no separate ACL model for CDR data.

See also