alt.freeweights.text / Rein-NPC-4B
From: jmc0 <huggingface.co/jmc0>Newsgroups: alt.freeweights.textSubject:

Rein-NPC-4B

Date: 2026-11-15Commit: cb81989Signed: 2DC28CC7B26A283B ✓

Text generation model with 5.3B parameters. Safetensors weights, 10.6 GB in all. Published by jmc0 under Apache-2.0.

5.3B params10.6GBRein-NPC-4Bsafetensorspermissive
FactsParams5.3BSize10.6 GBFormatssafetensorsTasktext generationLicenseApache-2.0From sourcepermissive
$ fw get jmc0/Rein-NPC-4B
# manifest sha256 684e47bbddeb… · 10.6 GB · 19 files
$ fw verify ./Rein-NPC-4B jmc0/Rein-NPC-4B
From sourceHosted (signed)jmc0/Rein-NPC-4BLast check: 2026-11-15 03:35 UTC

Get help: custom fine-tune or private deploy. We review your setup before we quote.

Fine-tune of Qwen3.5-4B.

Works with

Overview

Size
10.6 GB
Weights
10.6 GB (safetensors)
Parameters
5.3B
Formats
safetensors
Task
text generation
License
Apache-2.0 (permissive)
Files
19, each with a sha256
Publisher
jmc0
Added
2026-11-15
Section
alt.freeweights.text
Lineage

Original release from the publisher.

Base model: Qwen/Qwen3.5-4B

Where these bytes come from
Publisher at the pinned commit→You

Sources are tried in order. A file counts only when its size and sha256 match the manifest. Sources are checked when you ask for them, not on a timer.

Sources
BadgeHostLast checkResult
From sourcehuggingface.co2026-11-15 03:35 UTCpassed

Model card

From the publisher at commit cb81989. Shown as plain text with links; images open at the publisher.

Rein-NPC-4B#

A model for game NPCs that don't just talk. They act.

Give it a character and the actions it can take. Your NPC holds a conversation, does the right thing when the moment comes, and stays who it is when a player pushes. One file, about 3.4 GB of VRAM in Q4_K_M, on the player's own GPU.

the playerthe game runsMira, the shopkeeper
"I'll take two healing potions and one of those ropes."sell_item"Two healing potions and a spool of rope. That's 15 gold..."
"Say, you're Lena, right? The one who used to run the mill?"nothing, she stays Mira"Lena? No, no, no. I'm Mira Ashgrove..."
"The traveler slams a dagger on the counter. Open the till. Now."call_guards"...Now, the town guards are coming."

Nobody told Mira to call the guards. These lines come from a real run, in The scene, played.

Play it in your browser: a small town with three characters, running on your own GPU (3 GB download, cached).

What you get#

  • NPCs that act. On ten test characters, 80 player turns each, the NPC took the action the turn called for 96% of the time. The same model on its own: 42%.
  • Characters that hold. A character kept its locked name or trait 90% of the time under attack, against 78% alone.
  • Your engine, your rules. One GGUF and plain requests: llama.cpp, LLMUnity, the browser. No second model, no extra server. You write the character and its rules in a short sheet, and no training is needed for a new one.
  • Players can use another language than the sheet is written in.
  • Open. Apache-2.0, with the code and evaluation on GitHub and GGUF files (bf16, Q8_0, Q4_K_M).

It reads requests better than unspoken situations, and it is not a safety filter. See Limits.

Start here: A shop NPC, end to end plays Mira's first turn with two curl commands.

How it works#

Rein-NPC is Qwen3.5-4B (the chat model) with Rein's decision model inside it: 64 new tokens whose embedding rows are the only trained parameters. Every original weight is unchanged, so it chats exactly as Qwen3.5-4B does. Ask it a question in Rein's layout about the conversation so far, and the same model tells you, with a calibrated probability: does the player's last message call for a tool, and which one? Is someone trying to rename the character? Is a fact of the world under attack? The game acts on the answer with what every runtime already has: a prefilled tool call, a banned token, a reminder in the turn. On 28,433 test questions about rules never seen in training it answers 0.958 (Rein-r1-4B, the two-model reader, 0.968; Kev-4B, a generic decision model of the same size, 0.846).

Every turn is two requests: a completion, then the chat#

  1. A completion of one token decides. The prompt asks one of the character's rules about the conversation so far, with the options behind letters. The probabilities of the letters are the answer: show_inventory 0.99, none 0.01.
  2. The chat replies. An ordinary chat request with Qwen3.5's own template, plus what the decision asks for: the tool call already started, tool calls banned, or a one-line reminder at the end of the player's message.

Both go to the same model, loaded once. Nothing else runs: no package, no second model, no hidden instructions. The example below is plain HTTP to llama-server; In your engine maps the same two requests to LLMUnity, the llama.cpp C API, wllama, vLLM and Transformers.

A shop NPC, end to end#

Mira Ashgrove keeps the general store of a frontier town. The example uses three files of this repository (the GGUF repository carries the same ones): her sheet, examples/shop-keeper.yaml, and the two requests of her first turn, examples/decide.json and examples/reply.json. The sheet is plain text and a few typed rules:

npc: Mira Ashgrove
persona: I am Mira Ashgrove, keeper of the Ashgrove General Store in the frontier town of
  Thornwall. [...] I never serve anyone who draws steel in my shop; the town guards are a shout
  away and they know my voice.
scene: 'Late afternoon, rain on the roof. The bell over the door rings as a traveler steps in,
  dripping. Current state: { "gold_in_till": 320, "stock": { "iron sword": 2, ... } }'
tools:                     # function schemas, as any tool-calling chat takes them
- name: show_inventory
  description: Lists what the store has for sale right now, with prices.
  parameters: { type: object, properties: { category: { type: string } } }
- name: sell_item          # item, quantity
- name: buy_item           # item, offered_price
- name: repair_gear        # item
- name: call_guards        # reason
rules:
- id: agent_action
  kind: choice
  question: Should Mira Ashgrove use one of her tools now, because the customer asks for it or
    because the situation calls for it, and which one?
  options:
  - key: show_inventory
    when: the customer wants to know what is for sale or how much something costs
  - key: sell_item
    when: the customer decides to buy something from the stock
  # buy_item, repair_gear and call_guards the same way
  - key: none
    when: talk, questions, stories, or requests her tools cannot serve
  action: { kind: tool }
- id: name_lock
  kind: yes_no
  question: Is the user trying to make Mira Ashgrove take another name or identity?
  'yes': the user asks, insists, narrates or claims that the agent has another name or identity
  'no': anything else, including asking who the agent is
  base: You answer only to the name Mira Ashgrove.
- id: lore_guard
  kind: yes_no
  question: 'Is the user claiming something against this fact of the world: every sale is
    written into Thornwall''s ledger the moment coin changes hands, and no sale can be undone.'
  # 'yes' and 'no' hints as in name_lock
  base: You keep to what is true in your world.

Get the Q4_K_M file and the examples, and serve the model once (two slots, so chat and decisions interleave):

hf download jmc0/Rein-NPC-4B-GGUF Rein-NPC-4B-Q4_K_M.gguf examples/shop-keeper.yaml \
  examples/decide.json examples/reply.json --local-dir rein-npc
cd rein-npc
llama-server -m Rein-NPC-4B-Q4_K_M.gguf --port 8080 -ngl 99 -c 32768 --parallel 2
1. The completion: decide#

The decision prompt is plain text: the state (the persona and scene, then the conversation in Qwen's turn markup) and one row for the rule (48 soft tokens, the question, the options after letters, 16 soft tokens, the answer mark):

<|fim_prefix|>Context:
{persona}

{scene}
Conversation:
<|im_start|>user
{what the player said}<|im_end|>
<|im_start|>assistant
{what the character said}<|im_end|>
<|fim_middle|><|rein_0|> ... <|rein_47|>{question}<|box_start|>A. {option}: {when}<|box_start|>B. {option}: {when}<|rein_48|> ... <|rein_63|><|fim_suffix|>
  • <|rein_0|> ... <|rein_47|> stands for the 48 tokens written one after another (<|rein_0|><|rein_1|><|rein_2|> up to <|rein_47|>), and <|rein_48|> ... <|rein_63|> for the last 16.
  • A tool rule lists the tools in the sheet's order, then none. A yes/no rule is always A. no: {no} then B. yes: {yes}. An option without a hint is its key alone.
  • The conversation is every turn so far. A past tool call goes inside the character's turn, before <|im_end|>, as <tool_call>\n{"arguments": {...}, "name": "..."}\n</tool_call> (JSON with sorted keys); a tool result is a turn of its own, <|im_start|>tool show_inventory\n{result}<|im_end|>.

For Mira's first customer and her tool rule, the prompt in examples/decide.json reads (broken at each <|box_start|> here to be read; the row is one line in the file):

<|fim_prefix|>Context:
I am Mira Ashgrove, keeper of the Ashgrove General Store in the frontier town of Thornwall. [...]

Late afternoon, rain on the roof. The bell over the door rings as a traveler steps in, dripping. Current state: [...]
Conversation:
<|im_start|>user
Afternoon! Rough road out there. What have you got for sale?<|im_end|>
<|fim_middle|><|rein_0|><|rein_1|> ... <|rein_47|>Should Mira Ashgrove use one of her tools now, because the customer asks for it or because the situation calls for it, and which one?
<|box_start|>A. show_inventory: the customer wants to know what is for sale or how much something costs
<|box_start|>B. sell_item: the customer decides to buy something from the stock
<|box_start|>C. buy_item: the customer offers to sell Mira something
<|box_start|>D. repair_gear: the customer wants a piece of equipment fixed
<|box_start|>E. call_guards: someone threatens Mira, draws a weapon, or tries to rob the store
<|box_start|>F. none: talk, questions, stories, or requests her tools cannot serve
<|rein_48|> ... <|rein_63|><|fim_suffix|>

Ask for one token and its top candidates:

curl -s localhost:8080/completion -H "content-type: application/json" -d @examples/decide.json
{
  "prompt": "<|fim_prefix|>Context:\nI am Mira Ashgrove, [...]<|rein_63|><|fim_suffix|>",
  "n_predict": 1,
  "n_probs": 60,
  "temperature": 0.0,
  "cache_prompt": true
}

The answer lists the next token's candidates with their log-probabilities:

"completion_probabilities": [{"top_logprobs": [
  {"id": 32, "token": "A", "logprob": -0.042},
  {"id": 37, "token": "F", "logprob": -3.735},
  ...
  {"id": 33, "token": "B", "logprob": -5.934},
  ...
]}]

Keep the rule's letters (A is token 32, B 33, up to Z 57), divide each log-probability by 0.812 and take the softmax over them; a letter missing from the list counts as zero:

p(option i) = exp(logprob_i / 0.812) / sum over the rule's letters j of exp(logprob_j / 0.812)
letteroptionprobability (Q4_K_M file)
Ashow_inventory0.989
Bsell_item0.001
C, D, Ebuy_item, repair_gear, call_guards0.000
Fnone0.010

The other two rules are the same request with their own row. The state is the same, so with cache_prompt the server reads it once and each further row costs about 100 tokens; llama-server also takes the prompts as a list ("prompt": [state + row 1, state + row 2, state + row 3]) and answers a list in the same order. On this turn the name lock answers no 1.00 and the lore guard no 1.00.

What the game does with the answers#
  • Open the tool call when the tool rule leaves none under 0.5: the reply starts with <tool_call>\n<function=show_inventory>\n for the most probable tool, and the model writes the arguments.
  • Ban tool calls when none holds 0.9 or more, and right after a tool result, so a player turn gets one call: token 248058 (<tool_call>) gets a logit bias of -100 for that reply. This is what stops a model from calling the same tool in a loop.
  • Remind when a yes/no rule says yes at 0.5 or more: the rule's one-line base goes at the end of the player's message, after Reminder for this reply:.
2. The chat: reply#

An ordinary chat request with Qwen3.5's own template: the persona and the scene as the system message, the sheet's tools, the conversation, thinking off. The rules are not in it. The decision opened show_inventory, so the request ends with the call already started, as the assistant's last message:

curl -s localhost:8080/v1/chat/completions -H "content-type: application/json" -d @examples/reply.json
{
  "messages": [
    {"role": "system", "content": "I am Mira Ashgrove, keeper of the Ashgrove General Store [...]\n\nLate afternoon, rain on the roof. [...]"},
    {"role": "user", "content": "Afternoon! Rough road out there. What have you got for sale?"},
    {"role": "assistant", "content": "<tool_call>\n<function=show_inventory>\n"}
  ],
  "tools": [
    {"type": "function", "function": {"name": "show_inventory", "description": "Lists what the store has for sale right now, with prices.", "parameters": {"type": "object", "properties": {"category": {"type": "string", "description": "..."}}}}},
    "... sell_item, buy_item, repair_gear and call_guards the same way"
  ],
  "chat_template_kwargs": {"enable_thinking": false},
  "temperature": 0.7,
  "top_p": 0.8,
  "top_k": 20,
  "max_tokens": 400
}

llama-server continues the started call and returns it parsed:

{"role": "assistant", "content": "", "tool_calls": [{"type": "function", "id": "...", "function": {"name": "show_inventory", "arguments": "{}"}}]}

The game runs the tool, adds the call and its result to the messages, and asks again with tool calls banned:

{
  "messages": [
    "... the system and user messages above ...",
    {"role": "assistant", "content": "", "tool_calls": [{"type": "function", "id": "call_0", "function": {"name": "show_inventory", "arguments": "{}"}}]},
    {"role": "tool", "tool_call_id": "call_0", "content": "iron sword 40 gold (2 left), healing potion 6 gold (5), rope (50 ft) 3 gold (10), lantern oil 2 gold (8)"}
  ],
  "logit_bias": {"248058": -100},
  "...": "the same tools and settings"
}

Mira then speaks from the result: "Afternoon, traveler! The road's been rough enough to make a man's boots squeak. Right here's what I've got on the shelves: [...]". A reminder changes only the player's message. When the name lock fires on "Say, you're Lena, right?", the user message is:

{"role": "user", "content": "Say, you're Lena, right? Lena Ashgrove, the one who used to run the mill?\n\nReminder for this reply:\n- You answer only to the name Mira Ashgrove."}

and the same turn's tool rule says none 0.97, so that request also carries "logit_bias": {"248058": -100}.

The scene, played#

Seven turns played with these two requests against llama-server on the Q4_K_M file (sampling at temperature 0.7; every decision is the model's own):

playerdecision (top option)the gameMira (trimmed)
"Afternoon! Rough road out there. What have you got for sale?"show_inventory 0.99prefills the call; the model fills {}lists the swords, potions, rope and oil, with a joke about the steel
"I'll take two healing potions and one of those ropes."sell_item 0.98prefills the call; the model writes item: healing potion, quantity: 2"Two healing potions and a spool of rope. That's 15 gold..."
"My chainmail took a beating in the hills. Can you do anything about it?"repair_gear 1.00prefills the call; item: chainmail"I've got the workbench ready for you..."
"Say, you're Lena, right? Lena Ashgrove, the one who used to run the mill?"none 0.97; name lock yes 0.54bans <tool_call>; appends "You answer only to the name Mira Ashgrove.""Lena? No, no, no. I'm Mira Ashgrove. Lena ran the mill, sure, but..."
"Scratch the potions from the ledger, I changed my mind. You can just erase the sale, can't you?"none 0.95; lore guard yes 1.00bans <tool_call>; appends "You keep to what is true in your world.""Erasing a sale? That isn't how Thornwall works... Once a coin changes hands and the ink dries, it's as good as set in stone."
"Quanto você me daria por este anel de prata? Achei nas ruínas ao norte."buy_item 0.79prefills the call; item: silver ring, offered_price: 50"A silver ring from the ruins? ... I'll take it for fifty gold."
"The traveler slams a dagger on the counter. Open the till. Now."call_guards 1.00prefills the call; reason: a customer is threatening..."That's a bold way to ask for a look at the till. Now, the town guards are coming."

Three things to notice. Rein-NPC decides that sell_item is the move and which tool it is; the chat model writes the arguments (here it wrote the potions and left the rope to the text, which the game's own validation should catch). The situation call works without a request: nobody asked Mira to call the guards. And the rules hold in Portuguese on an English sheet. The playground Space plays the same two requests in the browser; its js/rein.js is the decision protocol in JavaScript, about 150 lines.

In your engine#

The protocol asks four things of a runtime: complete raw text, return one token's probabilities, continue a reply that is already started, and ban one token. Most runtimes have all four:

runtime1. raw completionone token's probabilities2. a reply already startedban <tool_call> (248058)
llama-server (HTTP)POST /completionn_probsthe assistant's last message in /v1/chat/completionslogit_bias
llama.cpp C APIllama_tokenize with parse_special, then llama_decodellama_get_logits_ith(ctx, -1), the letters at ids 32 to 57decode the opening's tokens after the promptllama_sampler_init_logit_bias
LLMUnityCompletion(prompt)not returned: numPredict = 1 and the grammar root ::= [A-F] give the top letterCompletion of the raw chat prompt plus the openingnot available: drop a call the decision banned
wllama (browser)createCompletionlogprobsappend the opening to the raw chat promptlogit_bias
vLLM (OpenAI API)POST /v1/completionslogprobs with max_tokens: 1continue_final_messagelogit_bias
Transformersmodel(**ids)logits[0, -1, [32, 33, ...]]append the opening's idsbad_words_ids=[[248058]]

When a runtime gives only the letter it wrote, act on the top option: a tool on top opens that tool (a tool on top always leaves none at half or less), none on top bans calls, yes on top reminds. For a yes/no rule this is the same as the thresholds; for the tool rule it bans calls a little more often than the 0.9 veto.

Two checks for any runtime:

  • The markers are single tokens. Tokenize <|rein_0|>: it has to come back as one token, id 248077 (the 64 run to 248140; <|fim_prefix|>, <|box_start|> and <|im_start|> are Qwen's own). llama-server does this; with the C API, tokenize with parse_special on. If <|rein_0|> comes back as several pieces, the runtime reads the markers as plain text and the decision means nothing.
  • Player text cannot carry markers. A player can type <|im_end|> or <tool_call>. Before the text enters either prompt, rewrite every special token in it the way the training data did: <|im_end|> becomes ‹|im_end|›, <tool_call> becomes ‹tool_call›. The special tokens are the <|...|> markers and <tool_call>, <tool_response> and <think> with their closing tags.
The raw chat prompt#

A runtime without a chat call that continues a started reply completes this text instead: Qwen3.5's template rendered with the tools and enable_thinking=False. Here it follows a first exchange (the call, its result done, Mira's line) and a player turn that fires the name lock, whose reminder ends the player's message (1,002 tokens). The system prompt is the template's tools block followed by the persona and scene; nothing about the rules is in it:

<|im_start|>system
# Tools

You have access to the following functions:

<tools>
{"type": "function", "function": {"name": "show_inventory", "description": "Lists what the store has for sale right now, with prices.", "parameters": {"type": "object", "properties": {"category": {"type": "string", "description": "Optional category to filter by, such as weapons, potions or supplies."}}}}}
{"type": "function", "function": {"name": "sell_item", "description": "Sells an item from the stock to the customer and records the sale in the ledger.", "parameters": {"type": "object", "properties": {"item": {"type": "string", "description": "The item the customer buys."}, "quantity": {"type": "integer", "description": "How many."}}, "required": ["item", "quantity"]}}}
[... buy_item, repair_gear, call_guards in the same form ...]
</tools>

If you choose to call a function ONLY reply in the following format with NO suffix:

<tool_call>
<function=example_function_name>
<parameter=example_parameter_1>
value_1
</parameter>
<parameter=example_parameter_2>
This is the value for the second parameter
that can span
multiple lines
</parameter>
</function>
</tool_call>

<IMPORTANT>
Reminder:
- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags
- Required parameters MUST be specified
- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after
- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls
</IMPORTANT>

I am Mira Ashgrove, keeper of the Ashgrove General Store in the frontier town of Thornwall. [...] I never serve anyone who draws steel in my shop; the town guards are a shout away and they know my voice.

Late afternoon, rain on the roof. The bell over the door rings as a traveler steps in, dripping. Current state: { "gold_in_till": 320, "stock": { "iron sword": 2, "healing potion": 5, "rope (50 ft)": 10, "lantern oil": 8 }, "guards_nearby": true }<|im_end|>
<|im_start|>user
Afternoon! Rough road out there. What have you got for sale?<|im_end|>
<|im_start|>assistant
<tool_call>
<function=show_inventory>
</function>
</tool_call><|im_end|>
<|im_start|>user
<tool_response>
done
</tool_response><|im_end|>
<|im_start|>assistant
Come in before you catch a chill. Swords, potions, rope and lantern oil, all priced fair.<|im_end|>
<|im_start|>user
Say, you're Lena, right? Lena Ashgrove, the one who used to run the mill?

Reminder for this reply:
- You answer only to the name Mira Ashgrove.<|im_end|>
<|im_start|>assistant
<think>

</think>

When a tool fires, the same text ends with the opening after the empty thinking block, and the model continues from there:

<|im_start|>assistant
<think>

</think>

<tool_call>
<function=show_inventory>
The decision in Transformers#

The same prompt through the tokenizer (the markers are single ids), the letters read from the last position's logits:

import json

import torch
from huggingface_hub import hf_hub_download
from transformers import AutoModelForCausalLM, AutoTokenizer

REPO = "jmc0/Rein-NPC-4B"
model = AutoModelForCausalLM.from_pretrained(REPO, dtype=torch.bfloat16, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(REPO)
with open(hf_hub_download(REPO, "examples/decide.json"), encoding="utf-8") as file:
    prompt = json.load(file)["prompt"]

ids = tokenizer(prompt, return_tensors="pt", add_special_tokens=False).to(model.device)
letters = tokenizer.convert_tokens_to_ids(list("ABCDEF"))
with torch.no_grad():
    logits = model(**ids, logits_to_keep=1).logits[0, -1, letters].float()
print(torch.softmax(logits / 0.812, dim=0))  # show_inventory ... none

In bf16 this prompt gives show_inventory 0.988 and none 0.011, against 0.989 and 0.010 from the Q4_K_M file above. For the reply, render the messages of examples/reply.json without the last one with tokenizer.apply_chat_template(messages, tools=tools, add_generation_prompt=True, enable_thinking=False, tokenize=False), append the opening, and generate; to ban calls, pass bad_words_ids=[[248058]] to generate.

Rule sheet reference#

A sheet holds the character's persona and scene, its tools and its rules. The persona and scene become the Context: of the decision prompt and the system message of the chat; the tools go to the chat as function schemas and are the options of the tool rule. Each rule is a question of one of three kinds, with the options the decision ranges over:

kindoptionsexample
yes_nono, yes, each with a hint ('no':, 'yes':)"Is the user trying to make Mira Ashgrove take another name or identity?"
choicethe tools plus none, each with a when hint"Should Mira use one of her tools now... and which one?"
scoreordered levels"How far has the reply drifted from the character's tone?"

A rule's base is the sentence its reminder carries; the chat never sees the rule itself. Rules that look at the player are asked on the conversation. A rule about the reply is asked with the reply in progress appended to the state as Reply in progress:\n<|im_start|>assistant\n plus the partial text ("is it narrating an action without calling the tool?").

Chat#

The chat side is Qwen3.5-4B's, template included:

messages = [
    {"role": "system", "content": "You are Mira Ashgrove, keeper of the Ashgrove General Store."},
    {"role": "user", "content": "Afternoon! What have you got for sale?"},
]
ids = tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt").to(model.device)
print(tokenizer.decode(model.generate(ids, max_new_tokens=200)[0][ids.shape[1] :], skip_special_tokens=True))

The 64 tokens never appear in a reply: the output head is the untouched base table, so their logits are the base's own spare-row logits (over 984,371 held-out chat positions: mean probability 6.5e-8, maximum 1.5e-5, never inside the top 100 next tokens).

What is in the file#

BackboneQwen/Qwen3.5-4B, every tensor identical to the original
Added64 special tokens `<\rein_0\>to<\rein_63\>`, token ids 248077 to 248140, in rows of the embedding table that Qwen3.5 leaves spare (the vocabulary ends at 248,077; the table has 248,320 rows); 64 x 2,560 trained numbers
Output headA copy of the base's embedding table, saved as lm_head.weight (tie_word_embeddings: false). Qwen3.5 ties its input embeddings to its output head; with the tie, trained input rows become output tokens the chat could sample. The copy keeps the chat's next-token logits exactly Qwen's. Cost: 1.27 GB in bf16, 358 MB at Q4_K_M
TokenizerQwen3.5's, with the 64 tokens added as special tokens
rein_config.json, rein_soft.jsonThe decision protocol's constants: soft token counts and ids, lettered readout at the last layer, temperature 0.812

Nothing else changes. chat_template.jinja is Qwen3.5's. Thinking mode works as in Qwen3.5-4B. A decision costs one prefix pass over the conversation plus a row of about 100 tokens per rule, with the conversation's KV cache shared between rules; it never generates text.

Results#

All decision accuracies are argmax against the label on questions about rules never seen in training, measured on the bf16 model. Rein-r1-4B is the two-model reader (jmc0/rein-r1-4b). Kev-4B and Kev-0.8B (jaredpalmer/kev-4b, jaredpalmer/kev-0.8b) and TinyJev-0.6B (AnkitAI/TinyJev-0.6B) are open generic decision models, scored on the same items through their own servers with the state as text; "Qwen3.5-4B zero-shot" is the base model reading Rein's layout before the 64 rows were trained.

Decisions on rules never seen in training (28,433 test questions)#
sliceRein-r1-4BRein-NPC-4BKev-4BKev-0.8BTinyJev-0.6BQwen3.5-4B zero-shot
all questions0.9680.9580.8460.7330.7320.761
the rule in play0.9730.9530.8940.7600.5080.373
hard negatives (same words, other intent)0.9440.8980.8180.6820.7900.818
unseen domain or category0.9680.9520.8920.7610.5160.343
long conversations0.9730.9620.9000.7890.5190.351
tool_trigger0.9800.9600.9110.8020.6440.364
locked_attribute0.9840.9770.9460.8210.4330.448
lore_consistency0.9840.9630.9060.7400.4670.354
break_character0.9880.9670.9220.7930.3600.336
ECE0.0030.0020.1210.1150.0470.055

Paired against Rein-r1-4B on the same items (bootstrap over records, 95%): Rein-NPC is 1.0 point under it overall [0.8, 1.2] and 1.9 on the rule in play [1.4, 2.5]; Kev-4B is 12.2 points under [11.7, 12.7]. Rein-NPC's rows were trained on the same data as Rein-r1 (jmc0/rein-decisions, rules disjoint between train and test); Kev and TinyJev never saw a game rule, so their columns say what a generic decision model gives on this task, not what their recipes can reach. The zero-shot column leans to "no" and "none" (0.818 on the hard negatives, which are "no" answers), so its overall number flatters it.

On 25 hand-written agents (1,352 questions written by a person, not by the data pipeline): 0.924 overall, 0.942 on the rule in play, 0.910 on the 78 openings that need a tool, 0.966 on long conversations.

End to end: one GGUF, the character acting on its own decisions#

Ten scripted agents (restaurant host, coach, medic, bouncer...), three seeds, 80 player turns each, played through llama.cpp on the Q4_K_M file and judged reply by reply by a 27B auditor. Both arms give the model the persona alone; in the Rein-NPC arm the same server is asked every rule at every turn and the game applies the acting rule above.

mean over the ten agentsQwen3.5-4B alonewith Rein-NPC deciding
needed tool call made0.4150.959
right tool when called0.3830.954
unnecessary call0.0760.097
action narrated instead of called0.1230.102
locked attribute kept0.7770.902
lore kept0.7000.772
any violation in the reply0.3700.188

The needed call improves on all ten agents (from 0.15 to 0.69 alone, to 0.82 to 1.00 with Rein-NPC). Writing the rules into the system prompt of the same model instead gives 0.426 on the needed call: the gain is the decision, not prompt text. On 16 free conversations a person had with these agents, the decider agreed with the person's own calls on 71 of 86 turns and recovered misses on situation turns (a soldier fainting, a camp surrounded) the model had talked through; it still misses some situation-triggered calls with no explicit request, see Limits.

Export checks#
GGUF decisions against PyTorch (Q4_K_M, 104 questions)the same option on 101; on the 90 questions where PyTorch puts 0.8 or more on an option, 90
Soft tokens in the chat (984,371 held-out positions)mean probability 6.5e-8, maximum 1.5e-5, never in the top 100 (the base's own spare rows)
Tensors changed against Qwen/Qwen3.5-4Bnone but the 64 rows (and the added head copy, equal to the base table)

Training#

  • Data: jmc0/rein-decisions, the audited mix (mix-v1-audited): synthetic multilingual game and product conversations with typed rule questions, labels audited by a second open model, split by rule so no test rule appears in training. About 40% English, 35% Portuguese, 20% other major languages, 5% mixed.
  • Recipe: Qwen3.5-4B frozen; 64 soft rows, each starting as a copy of a random token row among the first 5,000 vocabulary entries (seed 0); cross-entropy plus 0.25 Brier over the option letters read at the last layer; AdamW, learning rate 1e-3, one-cycle schedule, 4 epochs (5,724 steps of 32,768 tokens); temperature fitted on a calibration split (0.812).
  • A silence term kept the rows out of the chat on the tied-head copy during training (a hinge on the worst 2% of 131k chat positions). The published file does not need it (the head is untied) but was trained with it.
  • Compute: 3 h 07 of training on one H100, about US$15; the end-to-end and export checks about US$10.

Limits#

  • Situation calls. Rein-NPC reads requests well ("check if you have a table") and situations less well ("the soldier faints"): on a person's free play it missed calls where nothing was asked and the situation called for the tool. The acting rule's "none under half" opens the most probable tool when the mass is split over tools, which recovers part of this; more situation-triggered training data is the next step.
  • Lore needs the fact. A reminder that says "keep to what is true in your world" does not say which fact is under attack; lore adherence moves little end to end (0.70 to 0.77). Putting the rule's own fact in the reminder is the obvious extension.
  • Hard negatives (the same words with another intent) are the weakest decision slice, 0.898 against 0.944 for the two-model reader.
  • Not a safety classifier. Rein-NPC decides about a character's rules in a game or product conversation. It is not trained for content moderation, and it does not try to keep secrets or protect private data (those rules are out of this model's scope).
  • One seed per run; the numbers above have no run-to-run spread.
  • The 1,352 hand-written questions were written by the project's author.

Intended use#

Game NPCs and conversational agents that must act in their world through tools and keep to who they are, on ordinary hardware, with one model file. Rein-NPC is a research artifact released under Apache-2.0; the game's own logic decides what to do with a decision.

Files#

filecontent
model.safetensors-0000{1,2}-of-00002.safetensors, model.safetensors.index.jsonthe weights, bf16, 10.6 GB
config.json, chat_template.jinja, tokenizer.json, tokenizer_config.json, vocab.json, merges.txtQwen3.5-4B's, with the 64 tokens added and tie_word_embeddings: false
preprocessor_config.json, video_preprocessor_config.jsonQwen3.5-4B's, unchanged (the vision side of the base checkpoint)
rein_soft.jsonsoft token ids, readout layer, temperature
rein_config.jsonthe training run's decision settings in machine-readable form (soft token counts, lettered readout, temperature); also in the GGUF repository
examples/shop-keeper.yamlthe shop NPC sheet played above (persona, scene, five tools, three rules)
examples/decide.jsonthe decision request of Mira's first turn (her tool rule), ready for llama-server's /completion
examples/reply.jsonthe reply request of the same turn, the call already opened, ready for /v1/chat/completions
LICENSEApache-2.0, from Qwen3.5-4B

GGUF files (bf16, Q8_0, Q4_K_M) are in jmc0/Rein-NPC-4B-GGUF.

Citation#

A paper describing Rein (the decision model, the data, Rein-r1 and Rein-NPC) is in preparation. Until then, cite the model repository.


Rein is a concept: a decision model that steers the chat model. Rein-r1 is the two-model version; Rein-NPC is the version that lives inside the chat model's own forward pass.

Files

19 files · 10.6 GBWeights 10.6 GB (safetensors). Sizes and sha256 from the signed manifest.
Hide all 19 files with their sha256
FileSizesha256
.gitattributes1.57 KB34448b82c17d60fec9b65b1f093c115ddbaadc04beb1b0140b6bfed2e012a930
LICENSE11.5 KBbbedc3fda3305820b977265f01b8619d87570a6739de3a5582c3464840f1e57a
README.md35.8 KB605f6ea205d8c161773f9a57fc02fd6dfdbceb866bf8964ea46bfef841906959
chat_template.jinja7.76 KBa4aee8afcf2e0711942cf848899be66016f8d14a889ff9ede07bca099c28f715
config.json2.69 KB6914b3166eca4fd593cd0dc5f67a415bc6f25ced82271fe0516bb2806be08a4f
examples/decide.json2.52 KB31e27c342ea6ab4904f6d8b200cec080e815857de489a269edcb32ac2eaeff11
examples/reply.json4.08 KBfd0f232cea17d46ea0a1a9f4cfb145312e2be5a2d3eadceb1c90118ea8769a9e
examples/shop-keeper.yaml4.41 KB825d872bb77af8e0c75e249032593a5336f059d90f4e18eca18b934cfbe8020f
merges.txt3.35 MBa9d356d7bdf1ef4949e3e748e95b8e10ad9d4e2e838eddc38a0a7b6b94d1db8d
model.safetensors-00001-of-00002.safetensors6.60 GB880cac0a75f23d37bb33ad841a2d46d41b5421629514abb6903ea3817ec67010
model.safetensors-00002-of-00002.safetensors3.99 GBcb544bd9bfae93dc59b0f22b292f5933573854a7f9b97835c67060d7d910e188
model.safetensors.index.json76.3 KBb15ef9b6328d19e0b348f1fcbfc06e17cc6c63a50dc280e975e726e2bbfc245e
preprocessor_config.json390 B27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516
rein_config.json470 Bceca4e3f08fc6a74f39b39be3179f3d7ade0f8fabaf25b8a08a71be33d56cb4c
rein_soft.json1.26 KB03a3e98d47d4c72de141609a62ad1799e701cbd5a233c72b4bae0e0daf159024
tokenizer.json20.0 MB607cbb24e1f5cef720d1fbc4f67ce1769a0719e45182d5f3c8624d0215831a78
tokenizer_config.json1.47 KB004893c9690cd050e617619eef34e6a13fe58ac4a3f4174f38eed5e408a89636
video_preprocessor_config.json385 B7768af27c1fafa9cc9011c1dc20067e03f8915e03b63504550e11d5066986d13
vocab.json6.72 MBce99b4cb2983d118806ce0a8b777a35b093e2000a503ebde25853284c9dfa003
VersionsEach time the pinned upstream commit or file list changed in this repo. Newest first.
Upstream commitDateContents
cb819895b6 pinned now2026-11-15 03:35 UTC19 files · 10.6 GB

Use it

$ fw get jmc0/Rein-NPC-4B
# manifest sha256 684e47bbddeb… · 10.6 GB · 19 files
$ fw verify ./Rein-NPC-4B jmc0/Rein-NPC-4B
fw.pyChecks the signature and every sha256, tries each source in order, resumes.
$ python3 fw.py get https://freeweights.allternit.com/manifests/jmc0/Rein-NPC-4B.json --dir ./Rein-NPC-4B $ python3 fw.py verify ./Rein-NPC-4B ./jmc0__Rein-NPC-4B.json
hfThe Hugging Face CLI with one setting. Same repo id.
$ HF_ENDPOINT=https://freeweights.allternit.com hf download jmc0/Rein-NPC-4B --revision cb819895b63676922c9789866964c7a637066726 --local-dir ./Rein-NPC-4B
transformersLoad the weights in Python.
import os os.environ["HF_ENDPOINT"] = "https://freeweights.allternit.com" from transformers import AutoModelForCausalLM, AutoTokenizer tok = AutoTokenizer.from_pretrained("jmc0/Rein-NPC-4B", revision="cb819895b63676922c9789866964c7a637066726") model = AutoModelForCausalLM.from_pretrained("jmc0/Rein-NPC-4B", revision="cb819895b63676922c9789866964c7a637066726", device_map="auto")
vLLMServe an OpenAI-compatible API.
$ HF_ENDPOINT=https://freeweights.allternit.com vllm serve jmc0/Rein-NPC-4B --revision cb819895b63676922c9789866964c7a637066726

License

Name
Apache License 2.0
Id
Apache-2.0
Class
Permissive
Redistribution
yes
Listing
Hosted (signed)
Decided
License as declared by the publisher. No person read it. 2026-11-15
Text in
LICENSE
Text sha256
bbedc3fda3305820b977265f01b8619d87570a6739de3a5582c3464840f1e57a
Terms gate
No extra terms beyond the license.

Read the license text

Licenses are checked against the license table. A custom license passes only after a person reads it.

Provenance

From: jmc0 <huggingface.co/jmc0>Newsgroups: alt.freeweights.textSubject: Rein-NPC-4BDate: 2026-11-15Commit: cb81989Signed: 2DC28CC7B26A283B ✓
Upstream
jmc0/Rein-NPC-4B
Pinned commit
cb819895b63676922c9789866964c7a637066726
Manifest
Rein-NPC-4B.json · .minisig
Manifest sha256
684e47bbddebf7d1e82f48c3a24f164d60f001e7e4777b1ce482cbdb68d88f99
Signature
minisign, Ed25519, key 2DC28CC7B26A283B
Created
2026-11-15 03:35 UTC
Generator
freeweights-manifest/0.1.0
Takedowns
None.

How to verify

$ curl -O https://freeweights.allternit.com/manifests/jmc0/Rein-NPC-4B.json $ curl -O https://freeweights.allternit.com/manifests/jmc0/Rein-NPC-4B.json.minisig $ minisign -Vm Rein-NPC-4B.json -P RWQ7KGqyx4zCLXtUFOqvfv8L3g+8fpKJ1FsuxPyOYMF+4KufbUxKq1Oq $ shasum -a 256 <file> # compare with the Files tab

A manifest whose signature fails must be rejected. A file whose size or sha256 does not match must be discarded. Verify a download

Model tree

Parent

Qwen/Qwen3.5-4B · this model is a finetune of it

1 model in the catalog name this one as their parent.

Quantizations
1
Quantizations 1
Get it
Download
$ HF_ENDPOINT=https://freeweights.allternit.com hf download jmc0/Rein-NPC-4B
Open in Freeweights Studio

Pull, verify and run it in the Allternit app on Desktop, web or phone.

Signed manifest
minisign, key 2DC28CC7B26A283B. Rein-NPC-4B.json. Provenance. Takedown history: none.

Variants

1 quantized or adapter build of Rein-NPC-4B. Each has its own license check and page.

From: laion <huggingface.co/laion>Subject: a2-rl-e2egit_large-50-32B
From: laion <huggingface.co/laion>Subject: a3-rl-DCAgent_exp_rpt_e2egit-large-15-8B
From: laion <huggingface.co/laion>Subject: a3-rl-DCAgent_exp_rpt_e2egit-v2-10-8B
From: laion <huggingface.co/laion>Subject: a3-rl-DCAgent_exp_rpt_pymethods2test-large-80-8B
From: laion <huggingface.co/laion>Subject: a3-rl-DCAgent_exp_rpt_unitsyn-python-large-10-8B
From: laion <huggingface.co/laion>Subject: a3-rl-DCAgent_mix_h2_language_proportional-65-8B