Download docs/Holland_Scoring_Algorithm.md from ya02/kalim: direct link, hf CLI and curl.
- Browser
- Download file 8.98 kB
-
https://huggingface.co/spaces/ya02/kalim/resolve/main/docs/Holland_Scoring_Algorithm.md
- Command line
-
hf download hf://spaces/ya02/kalim/docs/Holland_Scoring_Algorithm.md
-
curl -L -o Holland_Scoring_Algorithm.md https://huggingface.co/spaces/ya02/kalim/resolve/main/docs/Holland_Scoring_Algorithm.md
A newer version of the Gradio SDK is available: 6.29.1
Holland Scoring Algorithm
Kalim Career Guidance Chatbot
Overview
This algorithm calculates the compatibility percentage between a student's Holland (RIASEC) personality codes and a major's RIASEC profile.
It is implemented in KalimRetriever.calculate_holland_score
(src/rag/retriever.py). There is no LLM anywhere in this path — the
ranking a student sees is deterministic Python over
data/processed/majors.json. The language model only phrases answers to
free-form chat questions.
The client hand-verified the output of this algorithm; the expected results are recorded in
Test_Results.docxin the repository root. After changing anything on this page, re-run a student with codesR-I-A(trackالكل, no language or location filter) and diff the top 10 against that document.
Input
Student: up to 3 RIASEC codes ranked by preference (1st, 2nd, 3rd)
Major: a holland_codes dict with primary, secondary and tertiary
score = retriever.calculate_holland_score(
['E', 'C', 'S'], # student, ranked
{'primary': 'E', 'secondary': 'C', 'tertiary': 'S'}, # major
)
The function is asymmetric. Swapping the arguments produces a different number — the first list is ranked by the student's preference, the second by the major's requirements, and the two carry different weights. A swapped call does not raise; it silently returns a plausible-looking wrong score, which reads as a scoring regression rather than a caller bug.
score(['R','I','A'], {R, A, S}) = 79.1 score(['R','A','S'], {R, I, A}) = 76.7
Weights
Major Position Weights
| Position | Weight | Reason |
|---|---|---|
| Primary | 50 points | Core personality fit |
| Secondary | 30 points | Supporting trait |
| Tertiary | 20 points | Complementary trait |
Student Preference Multipliers
| Rank | Multiplier | Reason |
|---|---|---|
| 1st choice | 1.0 (100%) | Dominant trait |
| 2nd choice | 0.8 (80%) | Secondary trait |
| 3rd choice | 0.6 (60%) | Tertiary trait |
Formula
For each student code that matches a major code:
Score += Major_Weight × Student_Multiplier
Maximum Raw Score = 50×1.0 + 30×0.8 + 20×0.6 = 86
Final Percentage = (Raw Score / 86) × 100 rounded to 1 decimal, capped at 100
Matching rules
The three rules below are not cosmetic — each was added to fix a real ranking bug, and removing any of them lets a worse major outrank a better one.
Duplicate student codes are dropped, keeping the earliest (strongest) position. Without this, a student who answered
['E', 'E', 'S']scores the major'sEslot twice and outranks a genuine three-way match.Each major position is consumed at most once. Once the major's
primaryhas been matched, a later student code cannot match it again. Matching is greedy in student order: the student's 1st choice picks its slot first, then the 2nd, then the 3rd.An absent major code never matches. A missing code is the empty string in the JSON, and
'' == ''would otherwise make two blank fields a match worth 30 or 20 points.
Only the first 3 unique student codes are scored. Empty inputs on either
side return 0.0.
Examples
Example 1: Perfect Match = 100%
- Student: [E, C, S]
- Major: Primary=E, Secondary=C, Tertiary=S
| Match | Calculation | Points |
|---|---|---|
| E(1st) → E(Primary) | 50 × 1.0 | 50 |
| C(2nd) → C(Secondary) | 30 × 0.8 | 24 |
| S(3rd) → S(Tertiary) | 20 × 0.6 | 12 |
| Total | 86/86 × 100 | 100% |
Example 2: Out-of-order Match = 69.8%
- Student: [I, A, R]
- Major: Primary=R, Secondary=I, Tertiary=S
| Match | Calculation | Points |
|---|---|---|
| I(1st) → I(Secondary) | 30 × 1.0 | 30 |
| A(2nd) → No match | - | 0 |
| R(3rd) → R(Primary) | 50 × 0.6 | 30 |
| Total | 60/86 × 100 | 69.8% |
Example 3: Single Match = 58.1%
- Student: [E, A, S]
- Major: Primary=E, Secondary=I, Tertiary=R
| Match | Calculation | Points |
|---|---|---|
| E(1st) → E(Primary) | 50 × 1.0 | 50 |
| A(2nd) → No match | - | 0 |
| S(3rd) → No match | - | 0 |
| Total | 50/86 × 100 | 58.1% |
Example 4: Duplicate codes de-duplicated = 58.1%
- Student: [E, E, S] → scored as [E, S]
- Major: Primary=E, Secondary=I, Tertiary=R
| Match | Calculation | Points |
|---|---|---|
| E(1st) → E(Primary) | 50 × 1.0 | 50 |
| E(2nd) → dropped as a duplicate | - | 0 |
| S(now 2nd) → No match | - | 0 |
| Total | 50/86 × 100 | 58.1% |
Without rule 1 the repeated E would have matched the primary slot a second
time for another 40 points, scoring 104.7% → capped at 100%.
Score Interpretation
The percentage is displayed to the student as a number; it is not bucketed
into levels or colours in the UI. The only banding in the code is the wording
of the match reason (_get_match_reasons), and it has three bands, not five:
| Score | Arabic phrasing shown |
|---|---|
| ≥ 50% | توافق عالٍ مع شخصيتك المهنية — high match |
| ≥ 30% | توافق جيد مع شخصيتك المهنية — good match |
| > 0% | توافق جزئي مع شخصيتك المهنية — partial match |
| 0% | no Holland reason is listed at all |
Permutation expansion
retrieve_with_expansion exists so a student is never shown an empty result
list. The plain retrieve scores every major against the codes in the order
the student ranked them, which for an unusual combination can leave almost
nothing above the threshold.
1. retrieve(top_k × 3), keep results scoring ≥ 50
(≥ 20 for a single-code student — one code in the tertiary slot
maxes out at 20/86 ≈ 23%, so a 50% threshold would return nothing)
2. If that yields ≥ min_results (default 10), return them. Done.
3. Otherwise re-score every candidate that passed the track / language /
location filters against ALL permutations of the student's codes,
keeping the best score per major.
4. Sort descending, drop anything below 50, return top_k.
A single-code student skips step 3 — there is nothing to permute, and step 1's lower threshold already covers all three positions.
The best-score map is keyed on (id, name_ar), not on either alone. The raw
dataset contained a reused id, and two majors can legitimately share a name
across different faculties; either key on its own silently collapses distinct
majors into one entry.
Note that an expanded result reports its best-permutation score, not the score for the order the student actually gave. So a major listed at 74% may be a weaker fit for the student's stated ranking; expansion only runs when the honest ranking could not fill the list.
Design Rationale
50/30/20 weighting: The primary code defines the major's core nature, so matching it matters most.
1.0/0.8/0.6 multipliers: A student's first preference is their dominant trait, so matches against it are weighted higher.
Normalized to 86: Makes a perfect match land on exactly 100%.
Greedy, single-consumption matching: Keeps the score monotonic — adding a code to a student's profile can never lower the score of a major that already matched.
Tests
tests/test_retriever.py covers the rules above (perfect match, asymmetry,
duplicate codes, empty major codes, ordering, and the expansion path):
python3 -m pytest tests/ -q
Kalim Chatbot - Lebanese University Thesis Project
Two scores in the expansion path
When fewer than min_results majors clear the score threshold,
retrieve_with_expansion re-scores every candidate under all six permutations
of the student's three codes and keeps the best. That best-permutation value is
kept as expansion_score and is what ranks the results and passes the
threshold — it is the whole point of the expansion.
It is not what the student sees. Because calculate_holland_score is
asymmetric (the order of the student's codes carries meaning), showing the
best-permutation number answers a different question from the one the interface
asks. Each expanded result therefore also carries holland_score, computed
from the order the student actually gave, and that is what the cards, the
results table, "توصية كليم" and the LLM context all display. Expanded result
sets are labelled as such in the results intro.
Worked example — a student with codes C-E-R and track فلسفة وانسانيات
gets 17 results through the expansion path. The top one, الإدارة السياحية
(triplet ECS), ranks on expansion_score 86.0 but displays holland_score
81.4, the score for C-E-R as stated.