Skip to content

Recommendation fit — requirements (WING-307)

Captured from Andrew, 2026-08-02, after the desktop app recommended summer internships to a user whose full senior resume was already on file. His words: "a user would find it insulting", "we are lying in our recommendations because they could be better", "this is a longstanding problem".

The defect

JobQueueService.GetRecommendedAsync scored candidates with fit.AnalyzeDeterministic(candidate, userSkills) where userSkills is a flat list of skill-name strings. No seniority, years, recency, proficiency, or role level.

Consequence — and it is perverse, not merely incomplete: an internship posting that lists the user's own stack scores highly, so the better a senior user's skills match, the more likely an internship is recommended to them. Kerr's "rewarding A while hoping for B": we reward name-overlap because it is easy to measure, and label the output "fit".

Data pollution (1486 of 1824 rows were SimplifyJobs internship listings, 81%) amplified the defect but is not its cause. Fixing the data without fixing the ranking leaves the bug live.

The signal we already have and throw away

IntakeProfile.SkillRecords (List<SkillRecord>) — built under WING-200, populated today by ExperienceFitService / ProfileService / SkillGrowthService, and read by the recommender: never.

Channel Field Meaning
Self-reported SelfReportedExperience.Years user-confirmed; null = unknown, 0 = a real confirmed zero
LastUsedOn, CurrentlyUsing, ConfirmedAt recency + confirmation provenance
Resume-backed ResumeBackedExperience.Years unioned years from cited accepted work-history ranges
Confidence, Contributions 0..1 attribution confidence + citations
Claim / Prove / Aspire SkillClaim, SkillProve, SkillAspire proficiency, evidence, growth intent

Standing rule recorded in the model — do not break it: "Producers must cite accepted work-history ranges and record confidence; consumers must never replace either channel with the larger or more favorable number." The two channels stay separate; unknown stays unknown.

Also unused by the recommender: WorkHistory (titles, dates, bullets, SkillsUsed), Education, Certifications, and JobPosting.EmploymentType.

Requirements

  1. Use everything we may legally and contractually use. Only user-provided data or what the posting itself states. No scraped third-party personal data.
  2. Never overstate. Unknown must stay unknown — never coerced to zero, never rounded to the more favorable channel. Overstating is the same lie as under-recommending.
  3. Per-skill years, not just skill names. A 12-year .NET engineer with 6 months of Kubernetes is a strong match for one posting and a weak match for the other; name-overlap rates them identically. Required for evaluating LinkedIn / Indeed / USAJOBS postings that state "5+ years of X" in free text.
  4. Skill → source attribution. Which jobs and which schools a skill was used at, so years are computable and auditable rather than asserted.
  5. Manual skill correction surface. A dedicated page or section to fix skills when LinkedIn or resume parsing gets them wrong — parsing is best-effort and the user must be able to correct it, including entering years per skill directly when the resume does not carry them.
  6. Management / leadership skills as their own section. Not ordinary tech skills: managed reports, number of direct reports, handled performance improvement plans, managed budgets, and similar scope signals. These are the "star skills" that distinguish senior candidates and are invisible to keyword matching.
  7. Coach-trainable. The structure must be something the AI interview coach can build on and train against — the same evidence that justifies a recommendation should ground coaching.
  8. Seed data must exercise it. All three demo personas updated so the logic is demonstrated honestly — including the first-timer, who should legitimately see internships.
  9. Management/scope skills must match against job descriptions. The leadership section is not decorative: postings state scope requirements ("manage a team of 5", "own a budget", "lead performance reviews"), and those must be matched against the user's recorded scope the same way technical skills are. Today a posting demanding people-management and a posting demanding none look identical to the recommender.
  10. Surface readiness for a step UP in seniority. This is the requirement that inverts the feature's purpose. The point is not merely "stop insulting people with internships" — it is to recognize when someone's accumulated years, scope, and skill depth mean they are ready for the next level and should be seeing (and applying to) roles above their current title. A user sitting at "Software Engineer" for six years with mentoring and budget scope should be shown senior/staff roles and told why they qualify. Downward suppression and upward promotion are the same computation read in both directions — a recommender that can tell you an internship is beneath you can tell you a staff role is within reach, and the second is where the real value is.

Leadership & scope signals — v1 shipped, additions pending council

Shipped: direct reports (had / count / largest team led), budgets (had / approximate), performance reviews & PIPs, hiring & interviewing, mentoring (had / count), cross-functional & stakeholder leadership, managing upward (influence without authority), soft-skill tags. Every field carries optional free-text evidence; null = UNKNOWN and is never coerced to false.

Added by Andrew, 2026-08-02 — not yet modeled: - Managing juniors — technical leadership of junior staff without formal authority: code review, onboarding, unblocking, assigning and checking work. Distinct from both DirectReports (no reporting line) and Mentoring (mentoring is developmental and optional; this is responsibility for their output). This is the single most common senior-IC scope signal and is invisible to the two fields we have. Capture: bool? + approximate count + evidence text.

Council verdict (2026-08-02) — additions and cuts

Highest-value adds, in the words postings actually use: - scope_of_ownership — enum: task / project / program / product-or-service / workstream / site / region / business-unit / P&L. The single most matchable signal ("own X"). - decision_authority — enum: contributed / recommended / approved / accountable-owner. The council calls this the anti-overclaim spine: it separates influence from ownership, so "I was in the room" cannot become "I owned it". - people_authority — enum: none / informal-lead / dotted-line / solid-line. This is Andrew's "managing juniors", captured without inventing a reporting line that does not exist. - managed_managers (bool? + largest org size) — the manager→director signal, missing from v1. - de_facto_lead (bool? + example) — "ran the team before holding the title". Promotion-readiness gold. - incident_or_crisis_leadership (bool? + severity/example) — cross-industry STAR gold: ER charge nurse, fire/EMS, ops on-call, military mission, retail crisis. - built_or_owned_process, scale_metric (free text: "40-bed unit", "12 stores", "500 tickets/wk"), operational_scope (multi-select), vendor_or_contract_ownership, multi_site_scope, p_and_l_or_revenue_ownership (bool? + magnitude band, never free text).

Never collect (legal / NLRA / discrimination): salary history or comp targets; protected traits, disability, age proxies, family status, religion, citizenship beyond work authorization; union membership, stance, or organizing activity (NLRA-protected — at most a neutral labor_environment_present flag, never the user's own involvement); clearance investigation detail (ITAR/export-sensitive — only clearance_held type + active); team demographics ("diversity of team led" invites discrimination in both directions).

Reframe: the PIP field must be captured as "conducted performance reviews / managed performance conversations" — and we must never record whether the user was on a PIP. Surfacing "put people on PIPs" as a boast reads adversarial and biases interviewers against the candidate's empathy.

Cut: free-text soft-skill tags — unmatchable noise and a pure overclaim magnet.

Soft skills, done properly (Andrew, 2026-08-02) — supersedes the plain cut

The council was right to cut free-text tags, but the answer is not to drop soft skills: it is to change their shape. Use a curated vocabulary attached PER JOB — the user picks, for each role in their work history, what they actually did there.

This fixes every objection at once: a fixed vocabulary is matchable against posting language; a tag scoped to a specific role is evidence-anchored and defensible in an interview; you cannot claim a scope signal without naming where you did it, which is a structural anti-overclaim guard; and each tag arrives at the coach already attached to a setting, so it is a STAR story rather than an adjective. Same treatment applies to the leadership/scope signals above — they belong per-role, not as profile-level assertions floating free of evidence.

Level is not a scalar — it is a package (Andrew, 2026-08-02). This supersedes tier-only logic.

Title tier alone is the wrong unit, and ranking on it will produce confidently wrong recommendations. Andrew's framing, which is correct:

"A senior at a Fortune 500 might take a mid at FAANG — but they wouldn't take a mid at another Fortune 500. A mid at a Fortune 500 might take a FAANG junior. It's a complex algorithm, but there is logic to it."

The logic: a candidate trades level against company tier, compensation, and cost of living. A nominal down-level at a materially higher-tier or higher-paying employer is an upward move in package terms; the identical down-level laterally is a pure downgrade nobody wants. So the comparison unit is:

package = f(title_tier, company_tier, total_comp, cost_of_living_adjusted_comp, location)

Consequences for the model: - Company tier must be captured and matched. FAANG / top-tier tech, Fortune 500, mid-market, startup, public sector, nonprofit. Both for the user's current employer and for the posting's. - Compensation must participate, subject to the legal constraint below: we may use the user's own stated target/expectation and the posting's stated range — we must NOT collect or rely on salary HISTORY (salary-history bans; council flagged this explicitly). - Cost of living normalizes comp by location. $150k in St. Louis ≠ $150k in the Bay Area; a role that looks like a raise nominally can be a pay cut in real terms, and vice versa. This is also what makes a remote or relocation recommendation honest. - A down-level is only "beneath you" when the package is also down. Hard-hide must therefore stay restricted to cases where no package can rescue it — an internship is never a senior's package regardless of employer, which is why the internship gate is safe as an absolute. - These are recommendations, not filters: the goal is surfacing trades a thoughtful candidate would genuinely consider, not enforcing a ladder.

Data available: contract/comp data and per-state cost-of-living data are already in the product's reach (offer evaluation uses cost-of-living today). Reuse that rather than building a second source.

Academic evidence for students, interns, and first-time seekers (WING-307)

This population has no work history, so their only evidence is academic: transcripts, DARS, coursework, GPA, projects, labs, clinicals, capstones, TA work, competitions. They are also the group most vulnerable to being pushed into overclaiming — they feel they have nothing, so an AI that inflates coursework into "experience" gets them caught in an interview.

The key structural insight (Andrew, 2026-08-02): for internships, coursework is never conflated to years. Internship and new-grad postings do not ask for years of experience — they ask for "coursework in X", "familiarity with X", "currently pursuing a degree in Y", "graduating by May 2027". So academic evidence maps directly onto what those postings actually require, with no years conversion anywhere in the path. Coursework in Data Structures satisfies "coursework in data structures" exactly — and claims nothing more.

The conflation risk exists only in the other direction: a student applying to a role that does state years. There, coursework must remain coursework and must never silently become "1 year of Java". This is why academic evidence belongs in its own channel with its own unit (courses, credit hours, recency, project artifacts) rather than as a third contributor to a years figure — it can then satisfy academic requirements fully while being structurally incapable of inflating a professional-years claim.

Pending council: GPA (when it is legitimate signal vs noise vs an equity hazard), what DARS may and may not be used to infer, honest representation of an in-progress degree for someone applying now, ranking of non-coursework evidence (capstone / research / clinicals / TA / competitions), federal education-substitution rules, and the exact anti-overclaim phrasing rules for generated resumes and interview answers.

Non-negotiables

  • A user with no work history on file is never filtered — genuine new grads and career changers must still see early-career roles.
  • Suppression must be visible and reversible: the user can see what was hidden and why, and turn the filter off. Someone may deliberately want a lower-level role.
  • Level inference from job titles is a coarse keyword heuristic and must say so wherever it is surfaced, matching the existing caveat in OfferEvaluationService.

Status

  • Landed: SeniorityFit (Core/Services) + a suppression gate in GetRecommendedAsync that withholds early-career postings from users whose own history puts them past that level. Narrow by design: only internship/new-grad, only with profile evidence.
  • Backed out before shipping: a naive YearsPerSkill helper that collapsed both experience channels into one optimistic number — it duplicated WING-200 and violated the never-take-the- larger rule. Per-skill years must be consumed from SkillRecords, not recomputed.
  • Open: items 3–8 above, pending council design review.