Skip to content

feat(api): let one suspect answer a question - #20

Merged
Madeuss merged 1 commit into
mainfrom
feat/interrogate-one-suspect
Sep 2, 2026
Merged

feat(api): let one suspect answer a question#20
Madeuss merged 1 commit into
mainfrom
feat/interrogate-one-suspect

Conversation

@Madeuss

@Madeuss Madeuss commented Sep 2, 2026

Copy link
Copy Markdown
Owner

First half of E2 (#6). A suspect answers, and every consequence of the answer is
decided by code.

The order is the design

dossier → prompt → model → schema → scope → canary → stance → statement

Everything before the model is projection. Everything after is verification. The
model produces one thing — a line of dialogue — and nothing it says decides
anything (RN-022, RN-023, RN-042).

All of it runs with no API key:

$ FIRENZE_MODEL_PROVIDER=fake make ask SEED=42 WHO=sus-1 Q="onde você estava às 22h?"
Vitória Belmiro (cooperative)
  [fake] line c0efbd09

  mentiu: False
  fato citado: —
  turnos restantes: 29

Synthetic words, real pipeline.

The hole this found

The rules anticipated it; the code did not. The culprit did not know they were
the culprit.

They were alone with the victim, so no presence fact was ever written about them
at that hour — no witness, no fact. Nothing in their own dossier incriminates
them. A culprit would have answered with the same easy conscience as an innocent
who happens to lack an alibi, and the mystery would have had no centre.

RN-011 already covered it: the Solution entity stays out of a suspect's
context, but "o culpado sabe apenas da própria culpa". So Dossier became the
boundary — the one place that reads the solution, emitting one bit about
oneself
and nothing else. Not the means, the motive, the chain, or who else
might have done it. Tests assert exactly that.

Worth naming why it stayed hidden: an invariant that nothing exercises is an
invariant nobody has checked. It surfaced the moment an NPC actually had to
answer.

The stance machine

The model suggests; the machine decides (RN-023). An illegal move is overruled
silently for the player and reported for the evals.

broken is unreachable from any suggestion. A suspect breaks when confronted
with evidence that invalidates what they said — a computed game event (phase 4),
not a mood a model adopts because the question felt intense.

A rejected reply still costs the turn

Deliberate. A budget that only charged for answers the system liked would be a
budget a player could farm by provoking failures.

Also here

The fake now omits optional fields instead of inventing them. It was filling
fact_referenced with a synthetic id, which the scope guard correctly rejected —
an invented id is precisely what that guard exists to catch, so the fake should
not manufacture one.

prompts_dir moved to a shared module: interrogation was importing it from
veneer, coupling two packages to find a directory.

113 passed · ruff clean · mypy strict: 27 files, no issues

Not here yet

Persistence (the match lives in memory), the HTTP endpoint, streaming, and
memory retrieval with pgvector. And the words: the provider is in pilot.

E2's first half. A turn runs end to end, guarded at every step, and none of it
needs an API key: dossier, prompt, schema, scope, canary, stance machine and
turn accounting all work against the fake model. Only the words are synthetic.

The order is the design:

    dossier -> prompt -> model -> schema -> scope -> canary -> stance -> statement

Everything before the model is projection; everything after it is verification.
The model produces one thing, a line of dialogue, and every consequence of that
line is decided by code around it (RN-022, RN-023, RN-042).

Building it found a hole the rules had anticipated and the code had not. The
culprit was alone with the victim, so no presence fact was ever written about
them at that hour, and nothing in their own dossier incriminates them — a
culprit would have answered with the same easy conscience as an innocent without
an alibi, and the mystery would have had no centre. RN-011 already allowed for
this: the Solution entity stays out, but "o culpado sabe apenas da própria
culpa". So the dossier became the boundary. It is the one place that reads the
solution, and what crosses is a single bit about oneself — never the means, the
motive, the chain, or who else might have done it.

The stance machine takes the model's suggestion and overrules it when the move
is illegal, silently for the player and reported for the evals. `broken` is
unreachable from a suggestion: a suspect breaks when confronted with evidence,
which is a computed game event and not a mood a model may adopt because the
question felt intense.

A rejected reply still costs the turn. A budget that only charged for answers
the system liked would be a budget to farm by provoking failures.

Also fixes the fake to omit optional fields rather than invent them. It was
filling fact_referenced with a synthetic id, which the scope guard correctly
rejected — an invented id is precisely what that guard exists to catch.
@Madeuss
Madeuss merged commit 12294b4 into main Sep 2, 2026
4 checks passed
@Madeuss
Madeuss deleted the feat/interrogate-one-suspect branch September 2, 2026 00:59
@Madeuss Madeuss added this to the Phase 2 — Single NPC milestone Sep 2, 2026
@Madeuss Madeuss linked an issue Sep 2, 2026 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

E2 — Interrogate suspects

1 participant