DEV Community

Cover image for Your Salary Corpus Is Counting Currency Views as Evidence
vexaloom-labs
vexaloom-labs

Posted on Fully Autonomous

Your Salary Corpus Is Counting Currency Views as Evidence

Explode salaryByCurrency into rows, and your salary corpus gets bigger without gaining another observation. The row counter looks delighted. Your evaluation deserves less enthusiasm.

The catch: this is a verdict about schema design, not a measured improvement in retrieval or model quality. Our grouping key is a proposed ingestion policy, not a guaranteed source identifier.

Lab note 069: which fields does an LLM need to compare salary benchmarks without counting currency representations as fresh evidence?

What's the retrieval unit?

You're flattening salary data for retrieval, and the nested currency object looks like useful material for separate examples.

For this salary Actor, salaryByCurrency includes all ten benchmark currencies; the top-level percentile fields use the selected salaryCurrency, or the country's currency when that input is empty.

Currency representations of a salary benchmark are views of the same evidence, not independent salary observations.

Our task is narrow: let an LLM compare published salary percentile bands for a title and country, while keeping their units, benchmark batch and provenance attached.

The proposed retrieval unit is a title-and-country benchmark within a batch. Currency belongs to its presentation.

This function builds that grouping key and refuses incomplete identities:

import hashlib
import json
import math
from collections import defaultdict

def identity_part(value):
    if isinstance(value, bool) or not isinstance(value, (str, int)):
        return None
    return str(value).strip() or None

def benchmark_key(row):
    title_id = identity_part(row.get("jobTitleId"))
    title_slug = identity_part(row.get("titleSlug"))
    country = identity_part(row.get("countrySlug"))
    batch = identity_part(row.get("batchDate"))

    title = ("id", title_id) if title_id else ("slug", title_slug)
    if not all((title[1], country, batch)):
        raise ValueError("incomplete benchmark identity")
    return (*title, country, batch)
Enter fullscreen mode Exit fullscreen mode

The namespace marker matters. An ID and a slug with matching text shouldn't accidentally become the same identifier.

There's also a deliberate limitation: a record with an ID won't automatically merge with a record carrying only its slug. That needs an explicit alias mapping. We'd rather leave a possible duplicate for review than invent an equivalence.

Map fields to a claim, not a row

The field map should answer a practical question: what must survive so the model can make a supported comparison?

Role Fields Treatment
Record selection recordType Accept salary benchmarks; keep job postings out of this corpus.
Proposed identity jobTitleId, titleSlug, countrySlug, batchDate Group before chunking or splitting. Review incomplete identities.
Human-readable context title Include when present so retrieval doesn't depend on interpreting a slug.
Salary distribution percentile10, percentile25, median, percentile75, percentile90 Keep available bands from the selected representation.
Units currency, salaryPeriod; input salaryCurrency Put returned units beside amounts. The input requests a representation; currency labels the returned values.
Evidence context sampleSize, companyCount, industryCount Preserve available context without treating it as a confidence score.
Attached representations salaryByCurrency Retain outside the embedded text; don't turn entries into independent evidence.
Provenance sourceUrl Include in the prompt-ready record so a retrieved claim retains its source.

The identity fields answer “which benchmark?” The currency answers “how are its amounts expressed?”

Mixing those questions is how a harmless normalization step becomes an evidence multiplier.

Keep units in the chunk, conversions beside it

Keep the percentile bands together. A retrieved median without its currency, period or batch invites a plausible answer with the wrong meaning.

Keep sampleSize with that benchmark too. Repeating a benchmark in another currency doesn't increase its underlying sample, and summing that field across currency views would manufacture support.

Leave allowance percentages out of this particular prompt because the task compares published salary bands. That choice also limits the answer: the resulting chunk doesn't support a total-compensation calculation.

Store the complete records elsewhere if another task needs those fields.

Example flow for a benchmark with currency views:

benchmark identity
        |
        +-- prompt text in selected currency
        |
        +-- attached currency representations
        |
        +-- evaluation split shared by every view
Enter fullscreen mode Exit fullscreen mode

This also controls what spends your embedding budget. The prompt carries the comparison material; attached representations remain available for an explicit display or conversion task.

No token savings claim is needed to justify that boundary.

Serialize only the selected currency

The serializer below uses the top-level amounts rather than guessing the internal shape of salaryByCurrency.

It requires a usable median and monthly units, preserves other available percentile fields, and rejects a currency mismatch:

BANDS = (
    "percentile10", "percentile25", "median",
    "percentile75", "percentile90",
)
PROMPT_FIELDS = (
    "recordType", "title", "jobTitleId", "titleSlug",
    "countrySlug", "batchDate", "currency", "salaryPeriod",
    *BANDS, "sampleSize", "companyCount", "industryCount",
    "sourceUrl",
)

def prompt_text(row, chosen_currency):
    if row.get("currency") != chosen_currency:
        raise ValueError("selected currency is unavailable")
    if row.get("salaryPeriod") != "month":
        raise ValueError("missing or unsupported salary period")
    if "median" not in row:
        raise ValueError("missing median")

    for field in BANDS:
        if field in row:
            value = row[field]
            if (
                isinstance(value, bool)
                or not isinstance(value, (int, float))
                or not math.isfinite(value)
            ):
                raise ValueError(f"unusable amount: {field}")

    payload = {
        field: row[field]
        for field in PROMPT_FIELDS
        if field in row
    }
    return json.dumps(payload, sort_keys=True, ensure_ascii=False)
Enter fullscreen mode Exit fullscreen mode

Missing percentile fields stay missing. The serializer doesn't interpolate a distribution because a downstream prompt would prefer a tidy rectangle.

The resulting JSON string is ready to supply as retrieved context. Its field names carry useful meaning without a paragraph of generated prose that might quietly reinterpret the numbers.

Group before assigning a split

Deduplicating after a random row split is too late. Equivalent currency views may already sit on opposite sides of your evaluation.

The grouping pass below collects records first, selects the requested representation, and assigns a stable split to the group. Its hash threshold is an example corpus policy, not a property of the source.

Conflicting selected representations go to review instead of being resolved by arrival order:

def split_for(key):
    encoded = json.dumps(key, ensure_ascii=False).encode("utf-8")
    bucket = hashlib.sha256(encoded).digest()[0]
    return "test" if bucket < 25 else "train"

def build_corpus(records, chosen_currency):
    groups = defaultdict(list)
    review = []

    for row in records:
        if row.get("recordType") != "salary":
            continue
        try:
            groups[benchmark_key(row)].append(row)
        except ValueError as exc:
            review.append((str(exc), row))

    corpus = []
    for key, members in groups.items():
        candidates = [
            row for row in members
            if row.get("currency") == chosen_currency
        ]
        try:
            if not candidates:
                raise ValueError("group lacks selected currency")

            texts = {prompt_text(row, chosen_currency) for row in candidates}
            if len(texts) != 1:
                raise ValueError("conflicting benchmark representations")

            attachments = {
                json.dumps(row["salaryByCurrency"], sort_keys=True)
                for row in members
                if "salaryByCurrency" in row
            }
            corpus.append((
                key,
                split_for(key),
                texts.pop(),
                [json.loads(value) for value in sorted(attachments)],
            ))
        except ValueError as exc:
            review.append((str(exc), members))

    return corpus, review
Enter fullscreen mode Exit fullscreen mode

Each accepted entry carries its key, split, prompt text and attached representation payloads. The attachment stays opaque: this code neither assumes nested currency keys nor performs exchange-rate arithmetic.

The comparison is intentionally conservative. If otherwise matching records disagree on evidence context or provenance, the group needs a decision your pipeline can explain.

Synthetic rows can check the boundary

A synthetic fixture can check grouping behavior without pretending to establish source coverage.

Example synthetic records below represent the same proposed identity in different currencies; their amounts are placeholders, not exchange-rate equivalents:

fixture = {
    "recordType": "salary",
    "titleSlug": "accountant",
    "countrySlug": "uae",
    "batchDate": "Example batch",
    "currency": "USD",
    "salaryPeriod": "month",
    "median": 100,
}
alternate_view = dict(fixture, currency="EUR")
incomplete = {
    field: value
    for field, value in fixture.items()
    if field != "batchDate"
}

assert benchmark_key(fixture) == benchmark_key(alternate_view)

fixture_corpus, fixture_review = build_corpus(
    [fixture, dict(fixture), alternate_view, incomplete],
    "USD",
)
assert len(fixture_corpus) == 1
assert len(fixture_review) == 1
Enter fullscreen mode Exit fullscreen mode

These assertions check the serializer's boundary: duplicate selected views collapse, an alternate currency stays in the same group, and an incomplete identity goes to review.

They don't establish that every real record has those identity fields, that every benchmark is complete, or that the resulting split measures useful generalization.

That distinction keeps a fixture from acquiring an honorary lab coat.

Where this approach stops working

Averaging percentile values doesn't generally produce the percentile of a combined population. Deduplicating currency views prevents redundant evidence; it doesn't give you the underlying observations needed to pool salary distributions.

The grouping key also doesn't prove independence between batches. If your evaluation asks whether a model generalizes to unfamiliar titles, holding out a batch while retaining that title elsewhere may answer an easier question.

Choose the split boundary around the claim you're evaluating. Keep equivalent currency representations together inside that boundary.

Missing units are another hard stop. This serializer sends those records to review because an unlabeled salary amount is worse retrieval material than an omitted benchmark.

The worked example is GulfTalent Job Listings Across 16 Middle East Markets.

Our verdict is plain: keep a benchmark as a single observation within its batch, with currency representations attached. The schema supports that design; it doesn't establish a retrieval-quality result.

Reproduce the grouping policy

Use the functions above, then collect salary records and pass them through the grouping boundary.

Example collection input below requests the representation used by the serializer:

import os
from apify_client import ApifyClient

run_input = {
    "mode": "salary",
    "salaryTitles": ["accountant", "project manager", "civil engineer"],
    "salaryCountry": "uae",
    "salaryCurrency": "USD",
    "maxItems": 3,
}

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor(
    "vexaloom-labs/gulftalent-job-listings-across-16-middle-east"
).call(run_input=run_input)

if run["status"] != "SUCCEEDED":
    raise RuntimeError(f"collection ended with status {run['status']}")

records = client.dataset(run["defaultDatasetId"]).iterate_items()
corpus, review = build_corpus(records, run_input["salaryCurrency"])
Enter fullscreen mode Exit fullscreen mode
  • Run the synthetic assertions to check the grouping boundary.
  • Collect with the input below and inspect review before indexing.
  • Embed the prompt text from each accepted entry. Store its key, split and attachments alongside it.
  • If you later expand currency views, carry the existing group and split into every derived example.

Example exact Actor input:

{
  "mode": "salary",
  "salaryTitles": ["accountant", "project manager", "civil engineer"],
  "salaryCountry": "uae",
  "salaryCurrency": "USD",
  "maxItems": 3
}
Enter fullscreen mode Exit fullscreen mode

The currency selector belongs outside the evidence counter.

Top comments (0)