Rate/margin awareness: implied pay rate per worker, bill rate per contract

Closes one of the Path 1 trust-break gaps. The scenario we kept flagging:
recruiter calls the system's top pick, worker quotes $35/hr, contract
pays $28/hr. First broken call kills the demo. This fixes it.

Heuristic (no schema change, derived at query time):
- Per worker: implied_pay_rate = role_base + (reliability × 4) + archetype_bump
  role_base: Electrician $28, Welder $26, Machine Op $24, Maint $26,
    Forklift Op $20, Loader $17, Warehouse Assoc $17, Quality Tech $23,
    Production Worker $18 ...
  archetype bump: specialist +4, leader +3, reliable +1, else 0
- Per contract: implied_bill_rate = role_base × 1.4
  (40% markup — industry norm: pay + overhead + insurance + margin)
- Worker is 'over_bill_rate' when implied_pay_rate > contract's bill_rate
  on a candidate-by-candidate basis

Backend (mcp-server/index.ts):
- ROLE_BASE_PAY_RATE + BILL_MARKUP constants
- impliedPayRate(worker), impliedBillRate(role) functions
- parseWorkerChunk() extracts role/reliability/archetype from vector text
- enrichWithRates() attaches implied_pay_rate on every /vectors/hybrid
  source response. Called from /search and /intelligence/permit_contracts.
- /search accepts optional max_pay_rate number — if set, filters out
  workers above that rate and reports pay_rate_filtered_out count.
- /intelligence/permit_contracts returns implied_bill_rate per contract
  AND over_bill_rate boolean per candidate.

Frontend (search.html):
- Live Contracts cards show 'bill rate: $X/hr' under the headcount line
- Each candidate shows 'pay $X/hr' in the sub-line; red 'Over bill rate'
  chip next to name when their pay exceeds the contract's bill rate
  (hover reveals the exact numbers and why it's flagged)
- Main 'Search all workers' results now include 'pay $X/hr' in the
  why-text (computeImpliedPayRate mirrored client-side to match Bun)

End-to-end verified live:
- Masonry Work permit, bill_rate $25.20/hr
  Kathleen M. Gutierrez pay $25.56/hr → 🔴 OVER
  Melissa C. Rivera pay $20.88/hr → 🟢 OK
- /search with max_pay_rate:32 filtered out 1 Toledo Welder above $32
- Main search shows 'pay $28.64/hr' in each result row

When real ATS data replaces synthetic workers_500k, same UI — the
client's real pay_rate column substitutes for the heuristic.
This commit is contained in:
root 2026-04-20 18:56:51 -05:00
parent a117ae8b38
commit af3856b103
2 changed files with 129 additions and 3 deletions

View File

@ -442,13 +442,24 @@ async function main() {
filter = `(${filter}) AND worker_id NOT IN (${ids.join(",")})`;
}
}
return ok(await api("POST", "/vectors/hybrid", {
const hybridRes = await api("POST", "/vectors/hybrid", {
question: b.question, index_name: b.index || "workers_500k_v1",
sql_filter: filter, filter_dataset: b.dataset || "ethereal_workers",
id_column: b.id_column || "worker_id", top_k: b.top_k || 5, generate: b.generate !== false,
use_playbook_memory: b.use_playbook_memory !== false,
playbook_memory_k: b.playbook_memory_k ?? 200,
}));
});
// Rate enrichment + optional max_pay_rate filter (soft filter,
// preserves result shape). Operator can opt out by omitting.
if (hybridRes && Array.isArray(hybridRes.sources)) {
enrichWithRates(hybridRes.sources);
if (typeof b.max_pay_rate === "number" && b.max_pay_rate > 0) {
const before = hybridRes.sources.length;
hybridRes.sources = hybridRes.sources.filter((s: any) => s.implied_pay_rate <= b.max_pay_rate);
(hybridRes as any).pay_rate_filtered_out = before - hybridRes.sources.length;
}
}
return ok(hybridRes);
}
// Tool: SQL
@ -1080,6 +1091,9 @@ async function main() {
min_trait_frequency: 0.3,
}).catch(() => ({} as any));
// Enrich with implied pay rate before taking the top-5
enrichWithRates(searchRes.sources || []);
const contractBillRate = impliedBillRate(role);
const sources = (searchRes.sources || []).slice(0, 5).map((s: any) => {
const name = String(s.chunk_text || "").split("—")[0]?.trim() || s.doc_id;
return {
@ -1088,6 +1102,8 @@ async function main() {
score: s.score,
playbook_boost: s.playbook_boost || 0,
playbook_citations: s.playbook_citations || [],
implied_pay_rate: s.implied_pay_rate ?? null,
over_bill_rate: (s.implied_pay_rate ?? 0) > contractBillRate,
};
});
@ -1114,6 +1130,7 @@ async function main() {
community_area: p.community_area,
issue_date: (p.issue_date || "").substring(0, 10),
},
implied_bill_rate: contractBillRate,
timeline: {
estimated_construction_start: estStart.toISOString().slice(0, 10),
staffing_window_opens: stagingDate.toISOString().slice(0, 10),
@ -1789,6 +1806,79 @@ async function runAlertsOnce() {
// Seed playbook_memory from a filled contract so the next hybrid query
// ranks against it. Used by both runWeekSimulation (per-day) and the /log
// endpoint (per manual logging). Fail-soft — seeding is best-effort.
// ─── Rate/margin awareness ──────────────────────────────────────────────
// Derive implied pay and bill rates per worker / per contract without
// schema changes. Numbers are industry heuristics — a real deployment
// would replace these with the client's actual ATS pay_rate column and
// contract bill_rate. The shape stays the same; only the source changes.
const ROLE_BASE_PAY_RATE: Record<string, number> = {
"Electrician": 28,
"Welder": 26,
"Machine Operator": 24,
"Maintenance Tech": 26,
"Forklift Operator": 20,
"Loader": 17,
"Warehouse Associate": 17,
"Material Handler": 18,
"Production Worker": 18,
"Quality Tech": 23,
"Line Lead": 22,
"Assembler": 18,
"Shipping Clerk": 19,
};
const DEFAULT_BASE_PAY = 19;
// Staffing firm typically marks up pay to bill by 35-45% to cover
// overhead, insurance, and margin. Using 40% as the midpoint.
const BILL_MARKUP = 1.4;
function impliedPayRate(w: { role?: string | null; reliability?: number | string | null; archetype?: string | null }): number {
const role = w.role || "";
const base = ROLE_BASE_PAY_RATE[role] ?? DEFAULT_BASE_PAY;
const rel = typeof w.reliability === "string" ? parseFloat(w.reliability) : (w.reliability ?? 0.5);
const relBump = (isFinite(rel) ? rel : 0.5) * 4;
const arch = (w.archetype || "").toLowerCase();
const archBump = arch === "specialist" ? 4 : arch === "leader" ? 3 : arch === "reliable" ? 1 : 0;
return Math.round((base + relBump + archBump) * 100) / 100;
}
function impliedBillRate(role: string | null | undefined): number {
const base = ROLE_BASE_PAY_RATE[role || ""] ?? DEFAULT_BASE_PAY;
// Contract bill rate = base pay × markup. This is what a staffing firm
// would typically quote for this role — the worker's rate has to be
// below this to keep margin.
return Math.round((base * BILL_MARKUP) * 100) / 100;
}
// Parse a worker's role / reliability / archetype from a vector chunk
// shaped like "Name — Role in City, ST. Skills: ... . Certs: ... .
// Archetype: reliable. Reliability: 0.93, Availability: 0.73"
function parseWorkerChunk(chunk: string): { role?: string; reliability?: number; archetype?: string } {
if (!chunk) return {};
const out: any = {};
const roleMatch = chunk.match(/—\s*([^\.]+?)\s+in\s+/);
if (roleMatch) out.role = roleMatch[1].trim();
const relMatch = chunk.match(/Reliability:\s*([\d\.]+)/i);
if (relMatch) out.reliability = parseFloat(relMatch[1]);
const archMatch = chunk.match(/Archetype:\s*([A-Za-z]+)/i);
if (archMatch) out.archetype = archMatch[1];
return out;
}
// Attach implied_pay_rate to each hybrid source in place, using either
// the row's native fields (from sql_results) or parsed from chunk_text.
function enrichWithRates(sources: any[]): void {
for (const s of sources || []) {
const parsed = parseWorkerChunk(s.chunk_text || "");
const w = {
role: s.role ?? parsed.role,
reliability: s.reliability ?? s.rel ?? parsed.reliability,
archetype: s.archetype ?? s.arch ?? parsed.archetype,
};
s.implied_pay_rate = impliedPayRate(w);
}
}
async function seedPlaybookFromContract(c: any) {
const names = (c.matches || []).slice(0, 5)
.map((m: any) => m.name || m.doc_id)

View File

@ -222,6 +222,22 @@ function api(path,body){
return fetch(A+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(function(r){return r.json()})
}
// Mirror of the Bun-side pay-rate formula so client-side renderers
// (main search, modal) can show a rate when they only have worker
// fields, not a pre-enriched hybrid source. Keep in sync with
// impliedPayRate() in mcp-server/index.ts.
var ROLE_BASE_PAY={Electrician:28,Welder:26,'Machine Operator':24,'Maintenance Tech':26,
'Forklift Operator':20,Loader:17,'Warehouse Associate':17,'Material Handler':18,
'Production Worker':18,'Quality Tech':23,'Line Lead':22,Assembler:18,'Shipping Clerk':19};
function computeImpliedPayRate(role,rel,archetype){
var base=ROLE_BASE_PAY[role||'']||19;
var r=typeof rel==='string'?parseFloat(rel):(rel||0.5);
var relBump=(isFinite(r)?r:0.5)*4;
var a=(archetype||'').toLowerCase();
var archBump=a==='specialist'?4:a==='leader'?3:a==='reliable'?1:0;
return Math.round((base+relBump+archBump)*100)/100;
}
function loadLiveContracts(){
// Pair live Chicago permits with our 500K worker bench and the
// meta-index discovered patterns for each role+geo. This is the
@ -258,6 +274,13 @@ function loadLiveContracts(){
var sub=document.createElement('div');sub.style.cssText='color:#545d68;font-size:10px;text-align:right';
sub.textContent='pool: '+(prop.pool_size||'?').toLocaleString()+' available';
right.appendChild(sub);
// Rate awareness: show implied bill rate per contract
if(c.implied_bill_rate){
var rate=document.createElement('div');
rate.style.cssText='color:#d29922;font-size:10px;text-align:right;margin-top:3px';
rate.textContent='bill rate: $'+c.implied_bill_rate.toFixed(2)+'/hr';
right.appendChild(rate);
}
hdr.appendChild(left);hdr.appendChild(right);card.appendChild(hdr);
// Description
if(p.description){
@ -286,8 +309,17 @@ function loadLiveContracts(){
chip.textContent='Endorsed · '+(cand.playbook_citations||[]).length+' playbook'+((cand.playbook_citations||[]).length===1?'':'s');
nm.appendChild(chip);
}
// Rate warning chip when worker's pay exceeds the contract's bill rate
if(cand.over_bill_rate){
var warn=document.createElement('span');warn.style.cssText='margin-left:6px;padding:2px 7px;border-radius:9px;font-size:9px;font-weight:600;background:#3a1a1a;border:1px solid #f85149;color:#fca5a5;vertical-align:middle';
warn.textContent='Over bill rate';
warn.title='Worker\'s implied pay rate ($'+(cand.implied_pay_rate||0).toFixed(2)+'/hr) exceeds contract bill rate ($'+(c.implied_bill_rate||0).toFixed(2)+'/hr) — margin at risk';
nm.appendChild(warn);
}
var sub2=document.createElement('div');sub2.style.cssText='color:#545d68;font-size:10px';
sub2.textContent=cand.doc_id+' · score '+(cand.score||0).toFixed(3);
var subText=cand.doc_id+' · score '+(cand.score||0).toFixed(3);
if(cand.implied_pay_rate) subText+=' · pay $'+cand.implied_pay_rate.toFixed(2)+'/hr';
sub2.textContent=subText;
info.appendChild(nm);info.appendChild(sub2);
row.appendChild(av);row.appendChild(info);
card.appendChild(row);
@ -933,6 +965,10 @@ function doSearch(){
var why='Reliability: '+Math.round((w.rel||0)*100)+'%';
if(w.avail)why+=' · Available: '+Math.round(w.avail*100)+'%';
if(w.archetype)why+=' · '+w.archetype;
// Derive and show implied pay rate client-side so the main search
// surface matches the live-contracts cards. Same formula as Bun.
var rate=computeImpliedPayRate(w.role,w.rel,w.archetype);
if(rate) why+=' · pay $'+rate.toFixed(2)+'/hr';
addWorkerInsight(out,w.name,detail.join(' · '),why,i,null,wd);
});
} else {