Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 5 min read

Two sector taxonomies, 20 ids against 4, and the one word they share means the opposite thing in each

CogniPrep asks two questions after signup: which employer you are applying to, and which role. The role answer comes from a closed list that lives in one file of 1,001 lines, and the shape of that list is worth a post on

CogniPrep asks two questions after signup: which
employer you are applying to, and which role. The role answer comes from a
closed list that lives in one file of 1,001 lines, and the shape of that list
is worth a post on its own, because the same word in it means two different
jobs depending on which module is reading.

Counted by importing the module rather than grepping it:

sectors:                     20
role ids:                    140   (121 hand-written, 19 generated)
sectors that have roles:      19
keyword strings:             725   (626 distinct)
roles with no keywords:        0
largest sector:               12 roles
source file:              32,421 bytes
the same data as JSON:    22,568 bytes

The 19 generated role ids are a catch-all appended per sector by a function
rather than written out nineteen times, so the id convention cannot drift:

function withOtherRole(sector: OnboardingSector): OnboardingSector {
  if (sector.roles.length === 0) return sector;
  return {
    ...sector,
    roles: [...sector.roles, { id: `${sector.id}-other`, label: 'Other / not listed', ... }],
  };
}

That guard in the first line is the thread I want to pull.

The sector is never asked. It is derived

The flow used to be two screens, sector then role inside it. It is now one,
because a candidate knows the title from the job advert and not our category
names. The sectors still exist, to group the roles and to give each search
result its second line, and the stored sector is whatever the picked role
belongs to:

/** The sector a role belongs to, which is how the sector answer is derived. */
export function getSectorIdForRole(roleId: string | null | undefined): string | null {
  return getRoleById(roleId)?.sector.id ?? null;
}

export function isValidSectorRolePair(sectorId: string, roleId: string): boolean {
  return ROLE_BY_ID.get(roleId)?.sector.id === sectorId;
}

The pair is validated together rather than each half independently, because a
client can otherwise post a valid sector and a valid role from a different
sector and produce a row whose two halves disagree. That is the whole reason
the second function exists.

Matching runs in the browser against the in-memory list. There is no search
endpoint, which is the stated trade in the file's own header: 22 KB of JSON is
cheaper to ship once than a round trip per keystroke, and it keeps the search
instant. All three importers are client components, so the list does ship.

The 20th sector has no roles, so the search cannot return it

19 of the 20 sectors carry roles. The last one is this:

{
  id: 'other',
  label: 'Something else / still deciding',
  keywords: ['not sure', 'undecided', 'unsure', 'dont know', 'other', 'open', 'anything'],
  roles: [],
},

It exists for the candidate who has not decided yet. Every result the ranker
emits, though, is emitted from inside a loop over sector.roles, so a sector
with no roles can never produce one. Running the real search function over a
few queries:

not sure     8 results
other        8 results
open         8 results   (Fintech & Payments, Learning & Development, ...)
undecided    0 results
unsure       0 results
dont know    0 results
anything     0 results

Four of its own seven keywords return nothing at all, and a fifth matches
unrelated roles on the substring. The two that behave return this:

Other / not listed (Banking & Finance)
Other / not listed (Accounting & Professional Services)
Other / not listed (Consulting & Strategy)
Other / not listed (Technology & Software)
... (8 of the 19 generated catch-alls, cut off by the result limit)

Eight rows with the same label, differing only in the second line, which is a
worse answer than none. The candidate who types "undecided" into a flow
with no skip button gets an empty list; the one who types "not sure" gets asked
to pick which sector they are not sure about.

The fix is not more keywords. It is that a bucket with no roles has no business
being in a list that is searched by role, so either it gets a single role of
its own or the escape hatch does the job instead. The escape hatch already
exists and is the sentinel id the free-text answer uses, capped at 80
characters, and isValidRoleId deliberately returns false for it.

20 ids in, 4 out

The other taxonomy is lib/sectors.ts, and it has four members:

export type Sector = 'engineering' | 'finance' | 'corporate' | 'public-sector';

These are the practice-content sectors: interview question pools, marking
profiles, exercise tags. One function joins the two taxonomies, and most of it
is a comment explaining what not to do:

 * The two taxonomies share a word but not a meaning, which is the trap here.
 * Practice 'engineering' is the technical *tech* repertoire - software, data,
 * architecture - so the onboarding family that feeds it is 'technology'
 * ("Technology & Software"), NOT the identically named onboarding
 * 'engineering' ("Engineering & Manufacturing": mechanical, civil, aerospace).
 * Matching on the shared word is exactly backwards: it hands a civil engineer
 * the system-design and model-validation content and marks a software
 * engineer against the corporate values/strengths rubric.

So technology maps to engineering, twelve families map to corporate,
finance and accounting map to finance, public-sector and healthcare
map to public-sector, and two of the twenty map to nothing:

    // Listed rather than left to fall through, so the absence of a mapping
    // reads as a decision and not an omission someone should "fix" back to
    // 'engineering' on the strength of the shared name.
    case 'engineering':
    case 'energy':
      return null;

null is a first-class answer here, not a failure: callers treat it as the
"General" session, drawn from the untagged pool with every sector still one tap
away. A confident wrong preselection reads as the product having misunderstood
your answer, which is more expensive than no preselection at all.

The fourth onboarding question used to be study level. It is gone, for the
reason written where it used to be, and the column it wrote is still there:

// Study level ("are you an undergraduate, apprentice, school leaver…?") used to be
// the fourth question. It is gone: it was never used to pick or difficulty-tune a
// test, and every extra screen loses people before the two answers that do matter.
// The `study_level` column stays in the table for rows the earlier flow wrote.

Two writers still set it, both to null.

See it

The four-member taxonomy is on a public page.
cogniprep.app/assessment-centre-practice
renders all four with their blurbs, and the Engineering one is the trap in
plain sight:

curl -s https://cogniprep.app/assessment-centre-practice \
  | grep -o "Technical graduate scheme scenarios[^<]*"
# Technical graduate scheme scenarios for software engineers, architects and data
# scientists - incident triage, design and build-vs-buy trade-offs, ...

Software engineers, architects and data scientists, under a heading that says
Engineering. A mechanical engineer reading that page is being shown the content
the mapping function refuses to give them, and the refusal is the right call.
cogniprep.app/interview names the same four
in prose, and section 8 of
cogniprep.app/privacy is the 20-sector
taxonomy described from the other end: a role from a fixed list, 80 characters
if you type your own, and the sector we work out rather than ask for.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.