Skip to main content

buildFromSources

import { tools } from 'tods-competition-factory';

Assemble a tournamentRecord from partial, heterogeneous or third-party sources — a set of API responses, a network-tab paste, an adapter's output — without knowing in advance what each one is.

It exists because the factory cannot control how other systems present their data, and because the alternative is every ingest tool re-deriving the same assembly logic in a place that has no access to the generators which already know the answer.

buildFromSources​

Pass an array of raw objects in any combination and any order. Each is classified by shape, routed to the extractor for its kind, and merged into one record. Draw links are then repaired so the result is readable — see Link repair below.

function buildFromSources(sources?: any[]): BuildFromSourcesResult;

interface BuildFromSourcesResult {
record: Record<string, any>;
classification: { index: number; kind: SourceKind }[];
unknownCount: number;
inferredLinks: { drawId?: string; inferred: InferredLink[]; issues: string[] }[];
unplacedMatchUps: UnplacedMatchUp[];
unbuiltStructures: UnbuiltStructure[];
derivedLinks: { drawId?: string; derived: DerivedLink[]; issues: string[] }[];
}

type SourceKind = 'event-data' | 'matchups' | 'participants' | 'unknown';
const { record, classification, unknownCount, inferredLinks, unplacedMatchUps } = tools.buildFromSources([
eventDataResponse, // { data: { tournamentPublicEventData: { ... } } }
scheduleResponse, // { tournamentMatchUps: [ ... ] }
participantsResponse, // { tournamentParticipants: [ ... ] }
]);

// classification: [{ index: 0, kind: 'event-data' }, { index: 1, kind: 'matchups' }, ...]

Nothing is dropped silently. Five of the seven returned keys exist only so the caller can see what the assembler could not do, or how it decided:

keyreports
classificationwhat each input was taken to be, by index — so a caller can show its work
unknownCountinputs matching no known shape. Reported rather than discarded
inferredLinkslinks added to make reconstructed draws readable, with confidence
unplacedMatchUpsmatchUps naming a structure their draw does not contain
unbuiltStructuressource structures that could not be built, and why
derivedLinksthe links given to each multi-structure draw, how, and any disagreement

A caller that would rather refuse than accept a repaired or lossy record inspects these and decides.

Classification​

Sources are sniffed rather than labelled. A single-key { data: { ... } } envelope is peeled recursively first — network-tab copies sometimes carry it and sometimes not — then:

  • a wrapper key (tournamentPublicEventData, tournamentMatchUps, tournamentParticipants) names the kind outright;
  • an array is classified from its first object member (matchUpId → matchups, participantId without eventInfo → participants);
  • an object is classified from its distinguishing keys (dateMatchUps → matchups, eventData → event data).

Anything else is 'unknown' and increments unknownCount.

classifySource, extractEventData, extractMatchUps and extractParticipants are exported separately for a caller that already knows what it holds.

unplacedMatchUps​

augmentDrawWithMatchUps inserts a matchUp only into an existing structure whose structureId matches. When no structure matches, the matchUp cannot be placed.

It is reported rather than dropped, because a shorter draw with no complaint is the hardest kind of loss to notice:

const { unplacedMatchUps } = tools.buildFromSources(sources);

// [{ matchUpId, eventId, drawId, structureId }]
// structureId is the one the matchUp NAMED — which no structure in that draw carries

Each entry carries enough identity to find the matchUp in the source. An empty array is the ordinary case.

Every structure of a draw​

Each drawsData entry carries every structure of its draw: the main structure and its consolations, playoffs and every COMPASS direction. All of them are built. The first is built exactly as it always has been, so a one-structure draw is unchanged. Each later structure is built from its own roundMatchUps; a CONTAINER (the round robin of ROUND_ROBIN_WITH_PLAYOFF) derives its groups as a first-structure container does.

Until this was fixed only the first structure was read, and the rest were dropped without a word. Measured 2026-10-14 across two ingest corpora, 12,603 of 33,221 matchUps were lost that way.

A structure that cannot be built (no structureId, or one already built) is reported, not dropped:

const { unbuiltStructures } = tools.buildFromSources(sources);
// [{ drawId, structureId, structureName, reason }]

The projection carries no links, and links cannot be recovered from structure shape alone (see inferDrawLinks). It does carry the drawType, so a multi-structure draw gets its links from the generator that knows them:

  1. drawType — the same drawType is generated at the same size, its structures are mapped onto the source's (stage + stageSequence + size, else name + size), and every link whose ends both map is copied. Generating the template draws nothing from the random source.
  2. playoff — a structure the template lacks, added to an elimination draw (Playoff 3-4, Playoff 5-8), is fed by the losers of the round of its source structure that has as many matchUps as the playoff has positions, TOP_DOWN, as addPlayoffStructures generates it.
  3. roundRobinPlayoff — a round robin playoff takes the finishing positions its participants held in their groups, by POSITION with feedProfile: DRAW. A playoff nobody has reached yet takes the number at the end of its name instead, reported as roundRobinPlayoffByName.

Each link is then checked against the source wherever the source can speak to it. A loserMatchUpId that lands outside the link's target, a source pointer no link covers, and a decided loser missing from an added playoff are each reported in issues, never resolved silently:

const { derivedLinks } = tools.buildFromSources(sources);
// [{ drawId, derived: [{ linkType, sourceStructureId, targetStructureId, basis }], issues: [] }]

Anything still unlinked is joined by repairDrawLinks and reported in inferredLinks.

Stored shape, TEAM events included​

Every source matchUp is query output, and so hydrated: it carries event and draw context, and each side carries a full copy of its participant. In a TEAM event the same is true of every collection matchUp inside every tie, on both sides, with the side's team attached too. The assembled record is therefore passed through dehydrateMatchUps before it is returned, so ties and collection matchUps are stored as the factory stores them: a tie's sides hold its lineUp, a collection matchUp's sides hold only their sideNumber, and every participant is read back from positionAssignments and the lineUps.

The sources carry a tieFormat only on each tie (getEventData has none at the event or draw level), so the assembler also gives each draw the tieFormat most of its ties share, and an event whose draws all share one keeps it on the event instead, as a factory-built TEAM event does. Dehydration then drops every tie's copy that equals what it inherits; a tie whose format really differs keeps its own.

Measured on a USTA playtennis TEAM tournament (121 teams, 127 ties, 2,467 collection matchUps): 19.7 MB without these steps, 4.3 MB with them, with every matchUp reading the same through the engine.

buildTournamentRecord​

The lower-level assembler, for a caller that has already separated its sources.

function buildTournamentRecord(args?: {
eventDataDocs?: any[];
matchUpDocs?: any[];
participantDocs?: any[];
unplaced?: UnplacedMatchUp[];
unbuilt?: UnbuiltStructure[];
derivedLinks?: { drawId?: string; derived: DerivedLink[]; issues: string[] }[];
}): Record<string, any>;
const record = tools.buildTournamentRecord({ eventDataDocs, matchUpDocs, participantDocs });

It builds every structure, derives links from the drawType as above, because a draw with more than one structure is unreadable without them, and returns its matchUps in stored shape (above). It does not repair links, deliberately: a caller assembling a record by hand opts in to inference via repairDrawLinks. Unplaced matchUps, unbuilt structures and derived links are collected only if the caller hands over an array to collect them into, so the function's shape is unchanged for callers that do not want the report.

A drawDefinition whose structures are not all joined into one group is refused by getDrawData — not degraded, refused (ERR_MISSING_STRUCTURE_LINKS). The record still saves and still lists in a calendar, so the failure is invisible until something asks for the draw.

Links are a property of the draw while matchUps are a projection of it, so reconstruction from matchUps can never preserve them. Inference is the only route back.

function inferDrawLinks(params: { drawDefinition: DrawDefinition }): InferDrawLinksResult;

interface InferDrawLinksResult {
links: DrawLink[]; // the links to ADD — existing links are never modified or removed
inferred: InferredLink[];
alreadyLinked: boolean; // the draw already formed a single group; nothing was needed
issues: string[];
}

interface InferredLink {
linkType: string;
sourceStructureId: string;
targetStructureId: string;
basis: string; // why this pairing was chosen, in the stages' own vocabulary
confidence: 'exact' | 'shape';
}

This repairs; it does not reconstruct, and it says which. Link count and feedProfile follow the drawType, not the structure shape:

drawTypestructureslinks
FIRST_MATCH_LOSER_CONSOLATIONMAIN, CONSOLATION2, LOSER round 1 + 2 TOP_DOWN
FEED_IN_CHAMPIONSHIPMAIN, CONSOLATION4, alternating TOP_DOWN / BOTTOM_UP
ROUND_ROBIN_WITH_PLAYOFFMAIN, PLAY_OFF1, POSITION with feedProfile DRAW
COMPASS87

Two draws with identical structures therefore have different correct links, and nothing can recover that from the structures alone. So inferDrawLinks emits the minimum set of links that joins every structure into one group, and reports how confident it is:

  • 'exact' — a two-structure draw whose stages name an unambiguous relationship.
  • 'shape' — more than two structures, or stages that do not name one. The draw becomes readable; the feed pattern is a guess and is flagged as one.

The rule is read from the data rather than assumed: a CONTAINER source (round robin) feeds by POSITION with feedProfile: DRAW; an ITEM source feeds by LOSER TOP_DOWN; and QUALIFYING feeds MAIN by position — matching generateQualifyingLink.

A caller that knows the drawType and wants the true feed pattern should generate the draw properly rather than repair it here. buildFromSources does exactly that for draws whose drawType it is given — see Links from the drawType — and repairs only what remains.

Applies inferDrawLinks across every drawDefinition in a record, mutating it in place and returning the report.

const inferredLinks = tools.repairDrawLinks(record);
// [{ drawId, inferred: [{ linkType, basis, confidence, ... }], issues: [] }]

Empty when nothing needed repair. buildFromSources calls this before returning; buildTournamentRecord does not.