# Ofrenda, an altar that remembers out loud with a 1.7B model that is not allowed to invent

> Source: <https://dev.to/samarajit/ofrenda-an-altar-that-remembers-out-loud-with-a-17b-model-that-is-not-allowed-to-invent-p0>
> Published: 2026-10-28 09:01:53+00:00

*This is a submission for the [Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine](https://dev.to/challenges/hacktoberfest-week4-2026-10-26).*

Ofrenda is a family altar you can talk to. You leave a stack of cards, one memory per card, each one written by somebody who was there. Then you light the candles and ask questions. The altar answers by holding up a phrase from one of the cards, word for word, and then saying one plain sentence around it. At the end of the night it prints a *recuerdo*, a printable page with every memory in the family's own words.

The rule I built it around: the model may not know anything the family did not write down.

I went with Día de los Muertos rather than the haunted house version of the theme, because the ofrenda has a job. It is not a way to talk to the dead. It is a way to make a family say their memories out loud while there is still time, and then to keep them somewhere that will not disappear when a service shuts down.

Three samples ship with it so it runs before you have written anything of your own. Abram Flores cut hair on South Sixth Street for forty-one years. Maria Elena Ruiz sold cempasuchil at the market for thirty. June Park ran a bulletin board called The Marigold off one phone line in her spare room, which is my nod to the other half of the theme, the part of the internet that is mostly gone now.

The repository is the demo: `python3 ofrenda.py serve` starts it on localhost with a local model. Everything below was recorded on the machine that also wrote this post. The screenshots are a headless browser talking to the real app. The timings come from a separate run with the network namespace cut down to a single loopback interface, so the numbers are what a machine with no internet actually produces.

Here is a real night with Abram's cards:

And the page the altar prints when the candles go out:

Three commands, all of them in the repository:

```
scripts/fetch-runtime.sh     # prebuilt llama.cpp into ./vendor
scripts/fetch-model.sh       # Qwen3 1.7B GGUF, about 1.1 GB
python3 ofrenda.py serve
```

There is a terminal version too, for people who prefer the dark:

```
python3 ofrenda.py seance --sample abram
```

`https://github.com/samarajit/ofrenda`

```
ofrenda.py           command line entry point
core/llm.py          llama.cpp on loopback, or any local OpenAI-compatible server
core/store.py        the record: person, cards, nights
core/retrieval.py    tf-idf, phrase picking, finding the disagreements
core/ground.py       the prompt, the checker, the retries, the verbatim floor
core/keepsake.py     the printable recuerdo
core/seance.py       the terminal version
core/server.py       a small http server, standard library only
web/                 the altar, drawn by hand in svg, css and plain javascript
tests/               31 tests, no model needed
```

No frameworks. The Python is standard library only, and the browser side is one HTML file, one stylesheet, and one script with no build step. That was not a purity thing. It was so that the whole thing fits in your head, and so that a family member with a laptop can still run it in ten years.

The stack is deliberately small:

`llama-server` bound to 127.0.0.1, spawned by the app and taken down with it.
Everything the program knows lives in one file, `data/altar.json`, and everything the model is allowed to say comes from that file.

My first build was the obvious one. Put the cards in the prompt, ask the model to answer warmly, cap it at four sentences. It sounded lovely and it was wrong in ways that would matter to the person using it.

Asked what happened the day a boy fell asleep in the barber's chair, the model invented a morning. It had the boy wake up the next day. It invented a customer who came to town, and then it invented a health problem for the child. Another answer quoted the card about a bowl of candies and then attributed the memory to the wrong granddaughter. On a benchmark that is a rounding error. At a family altar it is the one thing you cannot do.

So I stopped asking the model to be careful and made the harness be careful instead. The work is now split three ways:

A few of those rules, from `core/ground.py`:

```
if self._overlap(say, card["text"] + " " + card.get("by", "")) < 0.5:
    return False, "say used too many words that are not on that card"

stray = [word for word in tokens(say) if word not in set(tokens(record))]
if len(stray) > 1:
    return False, "say used words that are not on the cards: " + ", ".join(sorted(set(stray))[:4])

for author in authors - {cited_author}:
    if author and re.search(rf"\b{re.escape(author)}\b", say, re.IGNORECASE):
        return False, f"card {number} was left by {cited_author}, not by {author}"
```

When an answer fails, it goes back to the model once, with the phrase supplied by the harness so the model cannot pick wrong again. If the second try fails too, the card is read out loud, word for word, and the interface labels that turn *the card itself*. There is always a floor, and the floor is the family's own sentence.

One more thing I did not expect to end up in the code: the harness now polishes the model's sentence itself, stripping the "Rosa remembers that..." opener that the 1.7B insists on, instead of sending the answer back for another fifteen seconds. It is a boring `re.sub`, and it took the retry rate on my test box from nearly every answer to none in the final run. The model was being punished for a stylistic habit, which is not what the checker is for.

A two core Xeon with 1.9 GB of RAM and no GPU, which is roughly the worst laptop you could be handed:

| what | result | 
|---|---|
| model on disk | 1.11 GB, Q4_K_M | 
| memory held by llama-server | 1,753 MB | 
| a grounded answer, warm | 7.7 to 8.2 seconds | 
| a grounded answer, on this sandbox with a browser animating on the same two cores | 41 to 85 seconds | 
| a grounded answer that needed a retry | 15.0 seconds | 
| an answer the cards cannot support | 0.0 seconds, no model called | 
| tests | 31, none of them need a model | 

Four of the five questions in the final proof run were answered on the first try. One needed the second try; the fifth was a refusal that never called the model at all.

The 41 to 85 second line is the honest number from the screenshots, where a headless browser was drawing the altar on the same two cores as the model. The 8 second line is the same code with the animations paused. Neither of those is a laptop number; on an 8 GB machine with a GPU, or even a recent CPU, a sentence is usually one to three seconds.

The offline claim is the whole project, so I wrote it as a script rather than a promise. In a namespace with only `lo` up, `huggingface.co` and `api.openai.com` both failed name resolution, and the altar answered anyway:

```
scripts/prove-offline.py --out proof.txt
```

It prints the interfaces it can see, the model it loaded, every question and answer with the time and token count, the two failed connections, and the memory the model process is holding. Reproduce it on your own machine before you believe any of this.

Three things, in the order they turned out to matter.

**The material is a family's grief.** The cards are names, dates, small humiliations, a barber pole wrapped in a bedsheet in a garage. The obvious way to build this is to stuff them into a prompt and send them to an API. That means a family's dead grandmother is now training data in somebody's retention policy, and the tool stops working the moment the family cannot pay, or the company changes its terms, or the model is deprecated. Ofrenda keeps the record in `data/altar.json` on the machine that runs it. Delete the file and it is gone. There is no account to close.

**The model had to be small enough to argue with.** My grounding rules only exist because a 1.7B model misbehaves in specific, observable ways, and because I could run it a hundred times at no cost, watch it invent a morning, and write a rule for that. With a metered API I would have written fewer tests and accepted more slop. I also swapped models three times during development, between Qwen2.5 1.5B and Qwen3 1.7B and Qwen3 0.6B, by changing one file path. The fact that the weights are a file on disk is what made that a five second decision.

**The guardrails have to be readable by the person using them.** If a tool is going to mediate a conversation about someone's father, the family should be able to read the code that decides what it is allowed to say. `core/ground.py` is under six hundred lines of plain Python, and its imports are `json`, `os`, `re`, `time` and `dataclasses`. You can read it with the internet off. That is not a licensing footnote, it is the actual trust model.

Where open was plainly better than closed: the network. I could take the whole thing offline, put it in a namespace with one loopback interface, and prove the claim instead of asserting it. I could not have made that demo with a closed API, and neither could anyone who forks this.

The sample people are invented, but every detail on their cards is the kind of thing families actually keep: a bowl of candies by a register, a barber pole nobody could bring themselves to throw out, a grandmother who blamed the yeast. Abram's two granddaughters disagree on the card about whether he made any noise while he swept. I wrote that disagreement in on purpose, because every family has one of those, and because the altar is built to name it rather than settle it:

"the same three notes"

He hummed while he swept up, the same three notes over and over.

*borrowed from card 4, Elena*

The card right next to it, from Rosa, says he never made a sound. The altar will tell you both, and then leave you alone with it. When that behaviour finally landed, I sat back for a minute, which does not happen often with a side project.

If you try it, feed it a real memory about somebody and ask it something the cards do not cover. It should refuse, and it should refuse quickly, and that refusal is the most important thing in the project.
