candidate-retrieval.js

Overview

services/api/src/modules/match/candidate-retrieval.js implements the deterministic retrieval stage for Match candidates. It is the first Match entry point that touches Mongo, and it only performs coarse filtering plus bounded batching for a single Requirement.

The file keeps compatibility decisions separated. evaluateCandidatePair() from candidate-generation.js remains the only domain authority for final pair evaluation. This module only prepares the candidate set and resolves owner account status data.

[!IMPORTANT] This module is read-only. It does not create or update Listing, User, or operational records. It only reads Mongo and returns structured Match results.

How it works

generateCandidatesForRequirement() drives the whole flow. It validates the Requirement, resolves owner status, builds a coarse Mongo filter, reads Offers in _id order, and evaluates each row with evaluateCandidatePair().

flowchart TB
    req1[Requirement]
    gen1[generateCandidatesForRequirement]
    own1[resolveSingleOwnerAccountStatus]
    elig1[getListingMatchEligibility]
    filter1[buildCoarseOfferFilter]
    list1[Listing Mongo]
    batch1[query batches by _id]
    own2[resolveOwnerAccountStatusesBatch]
    user1[User Mongo]
    eval1[evaluateCandidatePair]
    out1[Candidates and meta]

    req1 --> gen1
    gen1 --> own1
    own1 --> user1
    gen1 --> elig1
    gen1 --> filter1
    filter1 --> list1
    gen1 --> batch1
    batch1 --> list1
    batch1 --> own2
    own2 --> user1
    batch1 --> eval1
    eval1 --> out1

The flow is intentionally conservative. The module never uses .skip(). It pages forward with a cursor based on _id, and it fetches one extra row per batch to detect whether more coarse candidates exist.

Key components

These are the main exported functions in this file. They define the retrieval boundary and the coarse filtering rules for Match.

{
    "title": "buildCoarseOfferFilter",
    "content": "Construye el filtro grueso de Mongo para Offers candidatos de un Requirement. Solo aplica gates de elegibilidad y compatibilidad inequ\u00edvoca.",
    "type": "function",
    "filePath": "services/api/src/modules/match/candidate-retrieval.js",
    "badges": [
        "public"
    ]
}
{
    "title": "generateCandidatesForRequirement",
    "content": "Genera candidatos de Offer para un Requirement. Valida el Requirement, consulta Mongo por lotes, eval\u00faa cada par y devuelve candidates junto con meta.",
    "type": "function",
    "filePath": "services/api/src/modules/match/candidate-retrieval.js",
    "badges": [
        "public"
    ]
}
{
    "title": "resolveOwnerAccountStatusesBatch",
    "content": "Resuelve accountStatus para varios ownerId en una sola consulta a User y devuelve un Map por _id stringificado.",
    "type": "function",
    "filePath": "services/api/src/modules/match/candidate-retrieval.js",
    "badges": [
        "read-only"
    ]
}
{
    "title": "resolveSingleOwnerAccountStatus",
    "content": "Resuelve accountStatus de un solo ownerId. Devuelve un sentinela interno cuando el due\u00f1o no existe o no puede resolverse.",
    "type": "function",
    "filePath": "services/api/src/modules/match/candidate-retrieval.js",
    "badges": [
        "read-only"
    ]
}

Data & fields

This module defines constants, a private sentinel, and the output contract for candidate retrieval. The values are fixed in the file and control scan limits, batching, and return metadata.

Input and output contract

[
    {
        "name": "requirement",
        "type": "object",
        "required": true,
        "description": "Listing con intent=\"requirement\". Puede ser un documento Mongoose o un objeto plano."
    },
    {
        "name": "options",
        "type": "object",
        "description": "Ajustes de retrieval.",
        "children": [
            {
                "name": "targetCandidateCount",
                "type": "number",
                "description": "Objetivo de candidatos retenidos. Se limita entre 1 y 200. Default: 50."
            },
            {
                "name": "queryBatchSize",
                "type": "number",
                "description": "Tamano del lote interno de Mongo. Se limita entre targetCandidateCount y 500. Default: 100."
            },
            {
                "name": "includeBlocked",
                "type": "boolean",
                "description": "Si es true, tambien retiene evaluaciones con status blocked. Default: false."
            },
            {
                "name": "countsTowardTarget",
                "type": "function",
                "description": "Predicado que decide si un candidato retenido cuenta para el objetivo. Default: todo retenido cuenta."
            },
            {
                "name": "maxEvaluatedOffers",
                "type": "number",
                "description": "Techo total de Offers evaluados en una llamada. Default: 1000."
            }
        ]
    }
]
[
    {
        "name": "requirementId",
        "type": "string | null",
        "required": true,
        "description": "Stringified _id del Requirement, o null cuando falta el Requirement."
    },
    {
        "name": "requirementEligibility",
        "type": "object",
        "required": true,
        "description": "Resultado de elegibilidad del Requirement.",
        "children": [
            {
                "name": "eligible",
                "type": "boolean",
                "required": true,
                "description": "Indica si el Requirement puede generar candidatos."
            },
            {
                "name": "reasons",
                "type": "string[]",
                "required": true,
                "description": "Motivos de elegibilidad o invalidez."
            },
            {
                "name": "isPublished",
                "type": "boolean",
                "required": true,
                "description": "Estado de publicación derivado."
            },
            {
                "name": "isPubliclyVisible",
                "type": "boolean",
                "required": true,
                "description": "Visibilidad publica derivada."
            },
            {
                "name": "lifecycleStatus",
                "type": "string | null",
                "required": true,
                "description": "Estado de lifecycle derivado."
            },
            {
                "name": "intent",
                "type": "string | null",
                "required": true,
                "description": "Intent derivado."
            },
            {
                "name": "ownerAvailable",
                "type": "boolean",
                "required": true,
                "description": "Indica si el owner pudo resolverse."
            }
        ]
    },
    {
        "name": "candidates",
        "type": "array",
        "required": true,
        "description": "Evaluaciones devueltas por evaluateCandidatePair()."
    },
    {
        "name": "meta",
        "type": "object",
        "required": true,
        "description": "Contadores y banderas del retrieval.",
        "children": [
            {
                "name": "queriedOfferCount",
                "type": "number",
                "required": true,
                "description": "Numero de Offers leidos desde Mongo para evaluacion."
            },
            {
                "name": "evaluatedCount",
                "type": "number",
                "required": true,
                "description": "Numero de Offers efectivamente evaluados."
            },
            {
                "name": "returnedCount",
                "type": "number",
                "required": true,
                "description": "Numero de evaluaciones retenidas en candidates."
            },
            {
                "name": "countedTowardTarget",
                "type": "number",
                "description": "Cuantos retenidos avanzaron el objetivo segun countsTowardTarget."
            },
            {
                "name": "targetCandidateCount",
                "type": "number",
                "required": true,
                "description": "Objetivo solicitado por el caller."
            },
            {
                "name": "queryBatchSize",
                "type": "number",
                "required": true,
                "description": "Tamano del lote interno usado."
            },
            {
                "name": "maxEvaluatedOffers",
                "type": "number",
                "required": true,
                "description": "Limite total de evaluaciones aplicado."
            },
            {
                "name": "batchesRead",
                "type": "number",
                "required": true,
                "description": "Numero de batches de Mongo consumidos."
            },
            {
                "name": "exhausted",
                "type": "boolean",
                "required": true,
                "description": "True cuando no quedaron candidatos gruesos y el safety cap no corto."
            },
            {
                "name": "hasMoreCoarseCandidates",
                "type": "boolean",
                "required": true,
                "description": "True cuando quedaban mas candidatos gruesos o el safety cap corto."
            },
            {
                "name": "safetyCapReached",
                "type": "boolean",
                "required": true,
                "description": "True cuando se alcanzo maxEvaluatedOffers."
            },
            {
                "name": "requirementValidationReasons",
                "type": "string[]",
                "required": true,
                "description": "Motivos de validacion del Requirement."
            }
        ]
    }
]

Fixed limits and projections

  • DEFAULT_TARGET_CANDIDATE_COUNT [badge:read-only] 50 — Objetivo por defecto para candidatos retenidos.
  • MIN_TARGET_CANDIDATE_COUNT [badge:read-only] 1 — Límite inferior para el objetivo.
  • MAX_TARGET_CANDIDATE_COUNT [badge:read-only] 200 — Límite superior para el objetivo.
  • DEFAULT_QUERY_BATCH_SIZE [badge:read-only] 100 — Tamaño de lote por defecto para lectura interna.
  • MAX_QUERY_BATCH_SIZE [badge:read-only] 500 — Límite superior para el lote interno.
  • DEFAULT_MAX_EVALUATED_OFFERS [badge:read-only] 1000 — Techo de seguridad para ofertas evaluadas en una llamada.
  • OFFER_SELECT_PROJECTION [badge:read-only] "_id createdBy intent publication.status lifecycle.status meta price location.declaration location.latitude location.longitude" — Proyección de campos que evaluateCandidatePair() realmente consume del lado Offer.
  • UNRESOLVED_OWNER_ACCOUNT_STATUS [badge:read-only] Symbol("match-retrieval:unresolved-owner-account-status") — Sentinela privado para distinguir dueño no resuelto de un owner realmente activo.

[!NOTE] OFFER_SELECT_PROJECTION incluye meta y price completos, pero limita location. This file only selects declaration, latitude, and longitude for geography.

Behaviors

The file is mostly made of small helpers plus one orchestrating function. Each behavior below is grounded in the implementation.

  • clamp(value, min, max, fallback) — Convierte a número, valida finitud, y devuelve un valor acotado. Si la conversión falla, devuelve fallback.
  • resolveListingId(listing) — Devuelve String(listing._id) cuando existe. Devuelve null cuando falta _id.
  • buildCoarseOfferFilter(requirement) — Construye el filtro grueso para Listing.find(). Excluye el mismo dueño, excluye intent: "requirement", exige publication.status: "published", excluye ciertos lifecycle.status, fija meta.entityType, y restringe meta.operationType con getCompatibleOfferOperationTypes().
  • resolveSingleOwnerAccountStatus(ownerId) — Resuelve accountStatus de un owner con User.findById(). Devuelve el sentinela interno si el owner falta o no existe.
  • resolveOwnerAccountStatusesBatch(ownerIds) — Resuelve varios owners en una sola consulta User.find({ _id: { $in: ownerIds } }). Devuelve un Map indexado por _id stringificado.
  • buildEmptyMeta({ targetCandidateCount, queryBatchSize, maxEvaluatedOffers }) — Construye metadatos vacíos con contadores en cero y banderas seguras.
  • generateCandidatesForRequirement(requirement, options) — Orquesta validación, lectura por lotes, resolución de owners y evaluación de pares. Devuelve un objeto estructurado con requirementId, requirementEligibility, candidates, y meta.

Coarse filter semantics

buildCoarseOfferFilter() only applies unequivocal gates. It does not reimplement domain compatibility. It intentionally leaves propertyType, price, physical fields, amenities, and location search logic out of Mongo filtering.

[!WARNING] The coarse filter is not the final Match decision. Final compatibility still comes from evaluateCandidatePair().

Candidate retrieval semantics

generateCandidatesForRequirement() validates the Requirement before querying Mongo. It resolves the Requirement owner status first, then calls getListingMatchEligibility() with that status. If the Requirement is invalid, it returns a structured empty result and never queries Listing.

It then scans Listing with _id pagination. Each batch requests queryBatchSize + 1 rows. That extra row is a sentinel for hasMoreCoarseCandidates. The scan advances by the last evaluated _id, even when the candidate is blocked.

Retention and progress counting

Two internal counters serve different purposes.

  • includeBlocked controls whether blocked evaluations are retained in candidates.
  • countsTowardTarget controls whether a retained evaluation advances targetCandidateCount.

This separation appears in the current implementation and is preserved in the returned metadata.

[!CAUTION] A retained candidate does not always count toward the target. This matters when countsTowardTarget is supplied by the caller.

Workflows

The retrieval flow follows a fixed order. The steps below reflect the actual runtime path in the file.

1. Validate Requirement | Resolve requirementId, owner status, and eligibility before any Listing query.
2. Build coarse filter | Create a Mongo filter for obvious Offer gates only.
3. Read one batch | Query Listing in _id order with a sentinel row.
4. Resolve owners in batch | Load owner accountStatus values for all offers in the batch.
5. Evaluate each pair | Call evaluateCandidatePair() for every fetched Offer.
6. Retain candidates | Keep non-blocked evaluations, and blocked ones only when includeBlocked is true.
7. Stop or continue | Stop at targetCandidateCount, at maxEvaluatedOffers, or when coarse candidates are exhausted.

The scan remains bounded even when the coarse filter is very permissive. It also remains bounded when compatibility rejects most Offers.

Gotchas & edge cases

These details are explicit in the source and affect how callers interpret the result.

[!WARNING] UNRESOLVED_OWNER_ACCOUNT_STATUS is deliberate. It prevents missing owners from being treated as active by fallback logic in getListingMatchEligibility().

[!CAUTION] maxEvaluatedOffers is a hard safety cap. The function can stop before reaching targetCandidateCount.

[!NOTE] The file never uses .skip(). It advances by _id cursor, which keeps pagination deterministic.

[!WARNING] includeBlocked does not influence stop conditions by itself. Only countsTowardTarget controls target progress.

[!NOTE] When the Requirement is invalid, the function returns a structured response. It does not throw for ordinary business invalidity.

[!IMPORTANT] candidate-generation.js remains the domain authority. This file never duplicates its final compatibility rules inside Mongo filters.

This module is tightly connected to a small set of verified files. Those files define the data models and the domain checks that this file depends on.

  • services/api/src/models/Listing.js — Mongo source for the candidate Offers.
  • services/api/src/models/User.js — Mongo source for owner accountStatus.
  • services/api/src/modules/listings/listing-match-eligibility.js — Provides getListingMatchEligibility() and getCompatibleOfferOperationTypes().
  • services/api/src/modules/match/candidate-generation.js — Provides evaluateCandidatePair(), the final compatibility authority.

[!NOTE] The file’s dependency direction is one way. Listing and User are read here, and evaluation is delegated outward.