Skip to content

feat(triage): convert foreign salaries before they meet the pay floor - #306

Open
MrReasonable wants to merge 1 commit into
mainfrom
feat/305-currency-aware-pay-floors
Open

feat(triage): convert foreign salaries before they meet the pay floor#306
MrReasonable wants to merge 1 commit into
mainfrom
feat/305-currency-aware-pay-floors

Conversation

@MrReasonable

Copy link
Copy Markdown
Owner

Closes #305.

What was wrong

The pay floors are denominated in GBP. Adverts are denominated in whatever their market uses. classify compared the two as bare numbers, which is invisible in a single-currency search and breaks in two distinct ways the moment a search covers more than one.

Nordic and Polish postings were not seen as money at all. _MONEY_RE recognised money only by £ $ € or a k suffix, so SEK 900 000, 900 000 kr and zl 250 000 parsed as nothing. Combined with this module's standing rule that no opinion never rejects, every posting in those markets passed the pay floor whatever it paid.

Euro and dollar figures were compared to a sterling floor at 1:1. A €105,000 role cleared a 100000 GBP floor while being worth roughly £90k. The euro floor read about 15% too generously, the dollar floor about 35%.

Both failed permissive, which is the cheaper direction, but the gate was not doing the job its name implies.

What this does

  • _salary_ceiling returns (amount, currency). A bare 90k reports None for the currency, meaning "the advert did not say" rather than "assume sterling", and _pay_reject applies that default visibly. That mirrors how the function already treats an unmarked pay basis as the caller's decision, and it preserves today's verdict for every existing sterling lead.
  • The parser reads a currency before or after the amount, because the Nordics write 900 000 kr, and accepts a space as a thousands separator.
  • New sluice/core/fx.py converts, from a rate table pinned in the release and overlaid by an optional cache. Unknown currency or missing rate returns None, and the caller abstains.
  • cfg.refresh_fx_rates, default off, lets a run refresh rates once at its start, and only when the cache is more than a week old. Never on the per-lead path.

Decisions worth reviewing

kr resolves to SEK. Sweden, Norway and Denmark all spell their currency that way and no regex settles which country a bare kr is in. SEK is the largest of the three by posting volume, and the three sit within about 15% of each other, so a wrong pick is far cheaper than the status quo of not parsing at all. An explicit ISO code always wins over the bare symbol, so NOK 1 100 000 is never read as SEK.

A pinned table, not a required fetch. A pay floor asks whether the advertised ceiling is under a number the user chose. Rates move a few percent a year, far below the precision that question needs, so a rate a month old changes essentially no verdicts. That is what makes an offline fallback honest rather than a correctness hazard, and why nothing here retries or fails hard.

Every failure path abstains. A malformed cache, an unreachable endpoint, an unknown currency, a non-positive quote: all return None and none raises. The module this feeds exists to remove fails-closed behaviour, and a conversion helper that could manufacture a reject would reintroduce it.

Three guards caught real mistakes

Worth naming, because each was a design error rather than a missing roster entry, and the tests were right every time.

  1. Reading XDG_STATE_HOME directly failed both the Homebrew formula sweep and the tilde roster. The cache path now goes through paths.resolve like every other store, which satisfies both by construction.
  2. Refreshing unconditionally in run() tripped the DNS guard. That was correct: it had quietly made the orchestrator network-touching for every embedder and every unit test.
  3. Gating that on a run() parameter which Sluice.triage passed moved the fetch onto the production path, so every e2e test reached the internet. The flag belongs in config beside company_resolve_fetch, which is the same shape of decision: a real network round trip, off unless asked for.

Deliberately not in scope

Per-market floors. One sterling number across several economies does not really answer "is this a good salary here", since cost of living, tax and equity norms all differ. Conversion is the prerequisite for that work, not a substitute for it, and doing both at once would make neither reviewable.

Testing

Full suite green, 6,049 tests, up from 6,039. ruff check clean.

New coverage, each written before the change and watched fail:

  • A krona, NOK or PLN salary is recognised as money at all.
  • The ceiling reports the currency it parsed, and a bare k figure reports None.
  • A EUR or SEK salary is converted before meeting the floor: €90,000 and SEK 900,000 reject against a 100000 floor, €150,000 survives.
  • An unconvertible currency abstains rather than rejecting.
  • An unmarked k figure keeps exactly today's behaviour.
  • A run refreshes stale rates once, does not refresh a fresh cache, and does not reach the network at all unless the config asks it to.

The pay floors are denominated in GBP; adverts are denominated in whatever
their market uses. `classify` compared the two as bare numbers, which broke in
two distinct ways the moment a search covered more than one currency zone.

DEFECT 1, the worse one. `_MONEY_RE` recognised money only by `[£$€]` or a `k`
suffix, so Nordic and Polish postings ("SEK 900 000", "900 000 kr",
"zl 250 000") parsed as no money at all. Combined with this module's standing
"no opinion never rejects" rule, every posting in those markets sailed past the
pay floor whatever it paid. Abstaining on what cannot be seen is correct and
stays; not seeing it was the bug.

DEFECT 2. `€` and `$` were compared to a sterling floor at 1:1. A EUR 105,000
role cleared a 100000 GBP floor while being worth about GBP 90k, so the euro
floor read ~15% too generously and the dollar floor ~35%.

- `_salary_ceiling` now returns `(amount, currency)`. A bare "90k" reports
  `None` for currency, which is "the advert did not say", not "assume
  sterling" -- `_pay_reject` applies that default visibly, exactly as it already
  decides what an unmarked pay BASIS means.
- The parser reads a currency before OR after the amount, since the Nordics
  write "900 000 kr", and accepts a space as a thousands separator.
- `core/fx.py` converts, from a rate table pinned in the release, overlaid by an
  optional cache. An unknown currency or a missing rate returns None and the
  caller abstains: a wrong reject bins a lead the user never sees.
- `cfg.refresh_fx_rates` (default OFF) lets a run refresh rates once at its
  start, and only when the cache is over a week old. Never per lead.

`kr` is deliberately resolved to SEK. Three countries spell their currency that
way and no regex settles which; SEK is the largest by posting volume and the
three sit within ~15% of each other, so a wrong pick is far cheaper than not
parsing at all. An ISO code always wins over the bare symbol.

Three of this repo's own guards shaped the result and are worth naming, because
each caught a real mistake rather than a bookkeeping omission:

- reading XDG_STATE_HOME directly failed the Homebrew formula sweep and the
  tilde roster, so the cache path goes through `paths.resolve` like every other
  store;
- refreshing unconditionally in `run()` tripped the DNS guard, which was right:
  it had made the orchestrator network-touching for every embedder and every
  unit test;
- gating that on a `run()` parameter which `Sluice.triage` passed moved the
  fetch onto the production path, so every e2e test reached the internet. The
  flag belongs in config, beside `company_resolve_fetch`, which is the same
  shape of decision: a real network round trip, off unless asked for.

Per-market floors are the real answer to "is this a good salary here" and are
deliberately not attempted; conversion is their prerequisite.

Closes #305

MrReasonable <4990954+MrReasonable@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 24 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 63 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Essentials

Run ID: ea5174d6-6b26-4a0e-951f-899d6eecf6eb

📥 Commits

Reviewing files that changed from the base of the PR and between d22eff5 and f4ea4d8.

📒 Files selected for processing (11)
  • sluice.yaml.example
  • sluice/core/fx.py
  • sluice/triage/classify.py
  • sluice/triage/config.py
  • sluice/triage/engine.py
  • tests/conftest.py
  • tests/test_classify.py
  • tests/test_core_layering.py
  • tests/test_path_tilde.py
  • tests/test_paths.py
  • tests/test_triage_engine.py

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

triage: pay floors are currency-blind, so non-GBP postings are judged wrong or not at all

1 participant