This project has been created as part of the 42 curriculum by sarfreit.
Teaching a 0.6B parameter model to speak the language of computers — one token at a time.
- What is this project?
- Background — LLMs, SDKs, and the Hugging Face ecosystem
- Project Structure
- Reading Order — Where to Start
- Documentation Index (
docs/) - Installation
- Usage
- The Makefile
- Algorithm Explanation — Constrained Decoding
- Design Decisions
- Bonus Features Implemented
- Performance Analysis
- Challenges Faced
- Testing Strategy
- Example Usage
- Resources
call me maybe is a function-calling engine for Large Language Models. Given a natural-language request such as:
"What is the sum of 2 and 3?"
...and a list of available functions, the program does not ask the model to answer the question directly. Instead, it produces a structured, machine-executable call:
{
"prompt": "What is the sum of 2 and 3?",
"name": "fn_add_numbers",
"parameters": { "a": 2.0, "b": 3.0 }
}The twist: the model used here (Qwen/Qwen3-0.6B) is small — only ~500M parameters — and small models are notoriously unreliable at producing valid JSON on their own (often under 30% success). This project does not rely on prompting alone. Instead, it implements constrained decoding: at every single token the model generates, the program masks out every token that would break the JSON structure or the function schema, significantly improving JSON and schema reliability by restricting each generated token to the set of valid continuations.
A few concepts worth knowing before diving into the code (all covered in more depth in docs/):
- LLM (Large Language Model) — a neural network trained to predict the next token in a sequence of text. It doesn't "understand" JSON or functions; it only ever predicts one token at a time, based on probabilities (logits).
- Tokenizer — text is not fed to the model as raw characters; it's split into sub-word units called tokens (e.g.
"Ġhello", whereĠmarks a leading space). Each token maps to an integer ID. - Hugging Face — the ecosystem (
transformers,huggingface_hub) this project's underlying model loading is built on. This project does not importtransformersdirectly — it only ever talks to the model through thellm_sdkpackage (see below). llm_sdk— a small wrapper package (provided for this project, copied into the repository) exposing aSmall_LLM_Modelclass with exactly four public methods this project is allowed to use:encode,decode,get_logits_from_input_ids, andget_path_to_vocab_file. No private/internal attributes ofllm_sdkare used anywhere in this codebase.- Constrained decoding — the core technique of this project: instead of letting the model freely pick whichever token has the highest probability, the set of allowed tokens is restricted at every step to only those that keep the output valid, before the model's choice is made.
📁 data/
Holds all JSON input and output. input/ has the function definitions and test prompts; output/ is generated at runtime and is gitignored.
📁 docs/
Ten topic-by-topic guides: LLM basics, the SDK, tokenization, constrained decoding, Pydantic, schemas, argparse, and uv.
📁 llm_sdk/
The provided model wrapper from the 42 School, copied here unmodified. Only its four public methods are used anywhere in this project — never private attributes.
📁 src/
Nine modules: CLI entry point, model and vocabulary setup, token validators, the constrained decoder, recovery logic, and JSON I/O.
| File | Role |
|---|---|
__main__.py |
CLI entry point — wires everything together |
env_setup.py |
Sets HF_HOME before any ML library import |
llm_engine.py |
Creates the model; builds the prompt text |
vocab_loader.py |
Loads vocabulary; builds token categories |
schemas.py |
Pydantic models for every data shape |
validators.py |
Pure functions: valid tokens per JSON state |
constrained_decoder.py |
State machine + the main generation loop |
recovery.py |
Retries failed generations; logs recovery |
data_loader.py |
Reads JSON inputs; writes JSON outputs |
Call-me-maybe/
├── data/
│ ├── input/
│ │ ├── functions_definition.json # available function schemas
│ │ └── function_calling_tests.json # prompts to process
│ └── output/ # generated at runtime (not versioned)
│ ├── function_calling_results.json
│ └── recovery_report.json
├── docs/ # topic-by-topic documentation (see below)
├── llm_sdk/ # provided model wrapper (copied, unmodified)
├── src/
│ ├── __main__.py # CLI entry point — orchestration only
│ ├── env_setup.py # configures HF_HOME before any ML import
│ ├── llm_engine.py # model creation + prompt construction
│ ├── vocab_loader.py # loads & pre-processes the model vocabulary
│ ├── validators.py # pure functions: valid tokens per JSON state
│ ├── constrained_decoder.py # the state machine + generation loop
│ ├── recovery.py # retry logic + recovery reporting (bonus)
│ ├── data_loader.py # JSON I/O for inputs/outputs
│ └── schemas.py # Pydantic models for all data structures
├── .flake8
├── .gitignore
├── Makefile
├── pyproject.toml
├── uv.lock
└── README.md
If you're reviewing this project for the first time, this is the recommended order:
schemas.py— the data shapes everything else is built on.llm_engine.py— how the model is created and how a prompt is built for it.vocab_loader.py— how the model's vocabulary is turned into a lookup structure.validators.py— the "rules" — pure functions answering "which tokens are legal right now?" for each JSON value type.constrained_decoder.py— where it all comes together: the state machine (_get_current_state,_get_tokens_for_state,_apply_token) and the main generation loop (generate_function_call).recovery.py— the retry/recovery layer wrapping generation.data_loader.py— reading inputs, writing outputs.__main__.py— the thin orchestration layer tying everything together via the CLI.
Each file in docs/ covers one topic in isolation, so any concept used in the code can be looked up independently:
| File | Covers |
|---|---|
LLM_Guide.md |
What a Large Language Model is and how token-by-token generation works |
LLM_SDK_Hugging_face.md |
The llm_sdk wrapper, its public API, and its relationship to Hugging Face transformers |
Tokenization.md |
How text becomes tokens, BPE space markers (Ġ, Ċ), and vocabulary files |
Constrained_Decoding.md |
The core technique behind this project — masking logits to guarantee valid structured output |
Function_Calling.md |
What function calling is and why it matters for LLM-based systems |
Schemas.md |
The Pydantic models used to validate every input and output in this project |
Pydantic.md |
General reference for the Pydantic validation library |
argparse.md |
Reference for Python's argparse, used to build the CLI |
UV_Guide.md |
Reference for uv, the package manager used for this project |
UV_Makefile_Example.md |
How uv commands are wired into this project's Makefile |
Requirements: Python 3.10+, and uv.
make installThis installs uv automatically if it isn't already available, then runs uv sync to install all dependencies (pydantic, numpy, and the local llm_sdk package) into a virtual environment.
The
llm_sdk/directory must sit alongsidesrc/(already the case in this repository) —uvresolves it as a local path dependency, seepyproject.toml.
uv run python -m src [--functions_definition <path>] [--input <path>] [--output <path>] [--model <name>] [--verbose]| Flag | Default | Description |
|---|---|---|
--functions_definition |
data/input/functions_definition.json |
Path to the JSON file describing available functions |
--input |
data/input/function_calling_tests.json |
Path to the JSON file with prompts to process |
--output |
data/output/function_calling_results.json |
Where the generated function calls are written |
--model |
Qwen/Qwen3-0.6B |
Hugging Face model name to load |
--verbose |
off | Prints a detailed, per-prompt generation trace |
See all options at any time with:
uv run python -m src --help| Command | What it does |
|---|---|
make install |
Installs uv (if missing) and syncs all project dependencies |
make run |
Runs the program with default arguments |
make run-verbose |
Same as run, with --verbose enabled |
make debug |
Runs the program under Python's pdb debugger |
make lint |
Runs flake8 + mypy with the mandatory flag set |
make lint-strict |
Runs flake8 + mypy --strict for stricter checking |
make clean |
Removes __pycache__ and .pyc files |
make fclean |
clean + removes the virtual environment |
make re |
fclean followed by install |
make help |
Prints this command list, then runs uv run python -m src --help |
For every token the model is about to generate, the pipeline is:
- Determine the current JSON state (
_get_current_state) from the string generated so far — are we writing the function name? A parameter key? A string value? Are we done? - Compute the set of legal next tokens for that state (
_get_tokens_for_state), delegating to a dedicated validator per value type (validators.py):get_valid_name_value_tokens— only tokens that continue a real function name fromfunctions_definition.json.get_valid_param_key_tokens— only tokens that continue an un-written parameter name of the selected function.get_valid_number_tokens/get_valid_integer_tokens— only tokens keeping the value a valid JSON number/integer prefix, offering,/}once the value is syntactically complete.get_valid_bool_tokens— only tokens continuingtrue/false, then the closing delimiter.get_valid_string_tokens— an anchored extraction strategy: a token is only allowed if the resulting partial string is still an exact substring of the original prompt. This prevents the model from inventing content and grounds every string value in the user's actual request.
- Mask the logits: every token outside that legal set is set to
-infbefore the highest-scoring token is picked (masked_logits), guaranteeing the model's choice is always structurally valid. - Apply the chosen token (
_apply_token) to update the parsing state (which parameter is being written, whether inside a string, etc.). - Repeat until the JSON is
"complete", then parse and validate it against the selected function's schema.
A dedicated regex-keyword shortcut handles one unavoidable limitation of anchored extraction: parameters expecting a regex pattern (e.g. "replace all vowels") can never be extracted from the prompt, because the regex syntax itself never appears there. For these cases, a small keyword-to-pattern map (REGEX_KEYWORD_MAP) injects the correct pattern directly.
- Separation of concerns: token-validity rules (
validators.py) are pure functions, independent of the decoder's internal state — easy to reason about and to test in isolation. - Anchored string extraction over free generation: rather than trusting the model to reproduce prompt content verbatim (which small models do unreliably), every string token is checked against the original prompt text before being allowed.
- Fail loudly, recover gracefully: any point where the constrained vocabulary would be empty raises a clear
ValueErrorimmediately, rather than silently falling back to an arbitrary token — see Performance Analysis and Bonus Features. llm_sdkisolation: only the four public methods are used anywhere in this codebase; no private attributes, ever.
| Feature | Status | Where |
|---|---|---|
| Support for multiple LLM models | ✅ | --model CLI flag, wired through llm_engine.create_model() |
| Advanced error recovery | ✅ | recovery.py — retries failed generations with an increasing token budget, logs every attempt, and exports a recovery report in the output folder |
| Visualization of the generation process | ✅ | --verbose flag — prints a formatted, per-prompt trace (state, token, token id, number of valid candidates) and a final success/failure summary on the terminal |
Multiple LLM models
uv run python -m src --model "Qwen/Qwen3-0.6B" --output data/output/test_default.jsonThe run should complete without crashing and produce valid, schema-compliant JSON in the output file. Any other model name can be passed the same way via --model.
Advanced error recovery
Force a recovery scenario with a prompt long enough to exceed the initial 200-token budget (the first attempt fails, the retry with a 400-token budget succeeds):
data/input/recovery_test.json:
[
{
"prompt": "Reverse the string 'this is a very long string deliberately crafted to force the constrained decoder to exceed the initial two hundred token generation budget so that the automatic recovery mechanism kicks in and retries with a larger budget of four hundred tokens which should then be enough to complete the full valid json output successfully without any further issues at all please keep adding more and more words here because the first attempt clearly did not use enough tokens to trigger the failure so this sentence needs to be substantially longer than before with many additional words padding it out until it finally crosses the two hundred token threshold and forces the recovery mechanism to actually kick in in this constrained decoding engine we are testing today for the call me maybe project at fourty two school porto and since one hundred and forty tokens was still not enough we are now adding an entire additional paragraph of padding text to make absolutely certain that this string pushes the total token count comfortably past the two hundred token mark so the recovery mechanism has no choice but to trigger on the very first attempt before succeeding on the second retry with the doubled token budget of four hundred tokens which will definitely be sufficient to hold this much longer piece of text from start to finish without truncation'"
}
]uv run python -m src --input data/input/recovery_test.json --output data/output/recovery_test_output.json --verbose
cat data/output/recovery_report.jsonThe terminal should show a failed first attempt (Generation did not complete within 200 tokens...) followed by a successful retry, and recovery_report.json should list the failed attempt instead of the "no recovery needed" confirmation.
Visualization of the generation process
make run-verbosePrints a per-token trace (state, token_id, token, valid candidate count) for every prompt, followed by a per-function success/failure summary.
- Accuracy: all 11 example prompts produce a valid, schema-compliant function call.
- Reliability: constrained decoding significantly improves the valid JSON structure — the model physically cannot emit a token that breaks the schema, because invalid tokens are masked to
-infbefore selection. - Speed: generation is dominated by the LLM forward pass, called once per generated token; typical prompts complete well within the 5-minute budget for the full test set on standard hardware.
- Safety net: if the valid-token set is ever empty (an edge case the state machine did not anticipate), generation fails immediately with a descriptive
ValueErrorrather than silently degrading into meaningless output.
- Greedy decoding degeneration: with plain
argmaxtoken selection, the model would occasionally fall into repeating short token sequences indefinitely (observed on regex-pattern parameters). Anchoring string extraction to the prompt text — plus the regex-keyword shortcut for genuinely un-extractable content — resolved this without resorting to sampling strategies outside this project's scope. - State machine edge cases: distinguishing "just opened a quote" from "just closed a quote", detecting when a structural
:follows the"parameters"key versus a regular key, and preventing premature}before every required parameter was written all required careful, iteratively-tested logic in_get_current_state/_get_tokens_for_state. - Token boundary quirks: the tokenizer sometimes merges a space with the following punctuation (e.g.
" ,"as a single token), which required normalizing comparisons (.strip()) rather than exact-matching raw token text.
The implementation was validated by running the full function_calling_tests.json set end-to-end and manually inspecting data/output/function_calling_results.json for schema correctness (right function selected, all and only the expected parameters present, correct types). Constrained-decoding behavior was iteratively verified with --verbose tracing, inspecting the token-by-token state transitions for each prompt to confirm the state machine's decisions at every step.
make install
make runuv run python -m src --verbose╭─── function 1/11 ──────────────────────────
│ prompt: 'What is the sum of 2 and 3?'
│ [name_value] token='fn'
│ [name_value] token='_add_numbers'
...
│ name: fn_add_numbers
│ parameters: {'a': 2.0, 'b': 3.0}
╰─── ✅ success
Function Calling
JSON Schema
uv Package Manager
Python & JSON
Prompt Engineering (used in llm_engine.build_prompt_request())
CLI Argument Parsing (argparse)
AI usage disclosure: AI assistance (Claude) was used throughout this project's development to help debug the constrained-decoding state machine (iteratively identifying and fixing edge cases such as quote-parity handling, etc.), to review code structure, and to help draft the documentation for this project.