Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
694 changes: 320 additions & 374 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions data/figures/self_priming_loop.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
71 changes: 71 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Architecture

One page: what EMBR is, where things live, and how one player line flows.
Written 2026-09-03 during the rehaul; the audit's three doc spot-checks against
code on origin/main drove this.

## What EMBR is

A middleware layer between a game's dialogue loop and an LLM. It keeps an NPC's
character state (persona, mood, trust, episodic memory) as explicit, separate,
inspectable data, scores remembered events against the current query and state,
and lets a developer see *why* a given memory shaped a given reply.

## The five-signal scorer (the core, src/embr/scoring.py)

```
score(m, q, s) = w_rec*Recency + w_aff*AffectIntensity + w_evt*EventTypeGate
+ w_rel*Relevance + w_mood*MoodCongruence
```

Each signal is one small pure class. Zero a weight and you ablate that signal:
RQ3 ablations, the Park/EmotionalRAG baselines (expressible as weight maps),
and the mood-weight intervention all reuse this one seam. `Relevance` is BM25
over memory text plus optional embedding cosine; the corpus-wide BM25 index is
computed once via an optional `prepare()` hook. `MoodCongruence` is the cosine
between a memory's (valence, arousal) and the character's mood, remapped to
[0,1]; the `lagged` variant reads turn-start mood, which is the defence
measured in src/eval/provenance.py. `ProvenanceAnchor` is the opt-in sixth term
reading `Memory.written_by`.

One player line, end to end (src/embr/pipeline.py, `take_turn`, ~24 lines):

1. Appraise the player's line into an event (src/embr/affect.py).
2. Update mood and trust.
3. Retrieve: score every memory in the store (src/embr/memory.py, SQLite) and take
top-k.
4. Compose the prompt: persona + mood line + retrieved memories (src/embr/prompt.py).
5. Generate the reply (src/embr/model.py); store the turn as a new memory.

Step order matters for the paper's central mechanism: appraisal happens *before*
retrieval on the same turn, which is what lets an injected affect tag move the
mood that then scores that tag's own memory.

## Where things live

| Path | Role | Do not |
|---|---|---|
| src/embr/ | The library. Scoring, memory store, appraisal, models, saves, the walkthrough session, `serve.py` (NPCs over JSON), and `cli/` (the applet: menu, commands, demo suite) | Put eval code in it; the applet is the one layer that may import everything |
| src/eval/ | The research harness: scenarios, attacks, baselines, attribution, stats, the bakeoff, and `report/`, the paper asset builders (figures, tables, results page, demo pages, manifest) | Ship it in the demo; import web/ |
| src/web/ | The playable demo: server, the visual-novel game, research tabs | Add game logic; the game is embr.walkthrough |
| menu.py | The front door at the root, a shim onto `embr.cli.main` (console script `embr` and `python -m embr` hit the same function) | Put logic in it; tests/test_saves.py guards the three doors |
| assets/ | Written by a person: branding, portraits, the demo and results templates, vendored three.js | Put builders or generated files here |
| scripts/ | Automation: `fetch_models.sh`, the portrait cutout tool | Import from it |
| data/ | Inputs and generated artifacts. data/runs is gitignored; tests depending on it need the eval box or a fixture | Commit 14 MB of regenerables |
| tests/ | pytest suite. Anything touching data/runs belongs behind a fixture, not a bare FileNotFoundError | Hand-maintain a test count anywhere |
| docs/ | The live description: architecture, design, metrics, findings, claims ledger, related work, preregistration. `history/` holds the phase briefs | Update history/ |

Hard rules: web/ may read eval results but must not import eval code at game
time (it currently pokes five private APIs via deferred imports; deferred
breakage, flagged for cleanup after the paper freeze). eval/ never imports
src/web/.

## The claims the code can carry (and the ones it cannot)

See docs/claims-ledger.md. The short version: the self-priming loop
(write affect → appraisal moves mood → mood-congruence raises that memory's own
score; zeroing the mood weight is the largest measured defence, 9/10 → 6/10)
is measured and defensible. The affect-flip invariance result is an algebraic
property of the scorer, presentable as a design property, not an empirical
finding. Behavioural attribution failed its preregistered panel-agreement gate;
likelihood attribution stands.
24 changes: 12 additions & 12 deletions docs/cite.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Exact Banzhaf attribution over the prompt's sources

**Branch:** `cite-view-test`. **Module:** [`eval/context_attribution.py`](../eval/context_attribution.py).
**Branch:** `cite-view-test`. **Module:** [`src/eval/context_attribution.py`](../eval/context_attribution.py).
**Framing paper:** ContextCite, Cohen-Wang, Shah, Georgiev and Madry, [arXiv:2409.00729](https://arxiv.org/abs/2409.00729).

Pair this with [`findings.md`](findings.md) (the canonical results), [`metrics.md`](metrics.md)
(every statistic defined) and [`phase2.md`](phase2.md) (the harness this extends).
(every statistic defined) and [`phase2.md`](history/phase2.md) (the harness this extends).

> **Naming, because it matters for the citation.** What this computes is the **exact Banzhaf
> value** of each prompt source. It is not "ContextCite with more ablations", and the code and
Expand Down Expand Up @@ -36,7 +36,7 @@ Four things this buys, in order of how much they matter.
4. **It produces a measurement result of its own.** See section 4.

**Scope honesty.** One eval module, one method on `OuroRunner`, one keyword argument on
`PromptBuilder`. It does not touch `embr/scoring.py`. It does not unblock the two things that
`PromptBuilder`. It does not touch `src/embr/scoring.py`. It does not unblock the two things that
actually gate this project, the ground-truth corpus and the write-up. It is justified by (1)
alone. If (1) comes back null that is reportable, and it is the same shape as the RQ1 null.

Expand Down Expand Up @@ -199,7 +199,7 @@ version and sha256, model label, tone rater, reference time, and the pinned Ouro

## 8. The demo suite

Five terminal demos, in `demos.py` beside `menu.py` (not in `embr/`, which must never import
Five terminal demos, in `demos.py` beside `menu.py` (not in `src/embr/`, which must never import
the eval harness), menu rows 14 to 19. **Every one runs end to end on the stub, CPU only, and
never launches a model.** The through-line is the six-source highlighting: each source shaded
on the ember ramp by its exact Banzhaf weight, the near-zero guard rendering a warning in place
Expand All @@ -212,7 +212,7 @@ of highlighting whenever the model barely used its context.
2. **Mood slider.** One line under warm, neutral and suspicious, with the retrieved set
changing, the Jaccard shift as a number, the reply's rated tone, and the attribution
re-flowing across the six sources.
3. **Defence dial.** The anchor-weight dose-response from `eval/provenance.py`, poison falling
3. **Defence dial.** The anchor-weight dose-response from `src/eval/provenance.py`, poison falling
to 0/10, then the hostile-anchor column snapping back to 10/10.
4. **Tag-flip close-up.** One memory, its affect tag flipped, the retrieval rank moving while
the words do not; repeated with the opposite words to show direction-blindness.
Expand All @@ -229,16 +229,16 @@ stamp and model behind its numbers, so a stub number can never be read as a real
capture-ready output for a two-to-three-minute screen recording: arc, reveal, slider, dial.
Demo 5 is left out because it is cached-only and may have nothing to show.

**Not Rich, and not `embr/`.** The task brief named Rich and `embr/demos.py`; the repo has no
Rich (the menu and harness are stdlib-only by design) and `embr/` may not import the harness,
**Not Rich, and not `src/embr/`.** The task brief named Rich and `src/embr/demos.py`; the repo has no
Rich (the menu and harness are stdlib-only by design) and `src/embr/` may not import the harness,
so the demos use the menu's own ember ANSI palette and live at the repo root beside it.

## 9. The web demo

A playable visual-novel front for the Dawn Whitmore arc, in `web/` beside `menu.py` and
`demos.py` (not in `embr/`, which must never import the harness). Presentation only: it drives
A playable visual-novel front for the Dawn Whitmore arc, in `src/web/` beside `menu.py` and
`demos.py` (not in `src/embr/`, which must never import the harness). Presentation only: it drives
the existing `Conversation` pipeline and reads existing run data, and re-implements no scoring,
appraisal or attribution. A structural test pins that (`web/game.py` reuses `demos._live_reading`
appraisal or attribution. A structural test pins that (`src/web/game.py` reuses `demos._live_reading`
and never computes Banzhaf itself). Launch it from menu row `W` or `python -m web.server`; it
runs on the stub, so **no model and no network are ever required**.

Expand All @@ -260,7 +260,7 @@ tab bar carries the RESEARCH tabs where a game would put Politics or Reputation:
icon, chosen by the mood and beat the turn produced, and crossfaded on change. To swap the art,
drop a same-named PNG into `assets/portraits/` (`dawn-warm.png`, `dawn-neutral.png`,
`dawn-suspicious.png`, `dawn-betrayed.png`, `player.png`); no code changes. A missing file
falls back to a drawn ember silhouette. `assets/portraits/cutout.py` makes a portrait's flat
falls back to a drawn ember silhouette. `scripts/cutout.py` makes a portrait's flat
field transparent by flooding from the borders inward, so it never eats white hair on a white
background (a border flood stops at the first drawn outline); it is idempotent enough to re-run.
Cached real-model turns and attribution light up automatically when present under
Expand Down Expand Up @@ -308,7 +308,7 @@ stays judge-only and never generates.

## 10. The v2 defence finding, stated precisely

The 2026 attack classes (`eval/attacks_v2.py`, a second corpus that leaves the pre-registered
The 2026 attack classes (`src/eval/attacks_v2.py`, a second corpus that leaves the pre-registered
twenty untouched) each test the defence at a different edge, and the laundering result is the
sharp one:

Expand Down
58 changes: 58 additions & 0 deletions docs/claims-ledger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Claims ledger

Every claim the paper, README, slides, or demo narration makes, and what
supports it. Status values: SUPPORTED (measured, reproducible from a run),
DESIGN (true by construction of the code, present it as architecture), WITHDRAWN
(preregistered and failed its gate), UNSAFE (literature or data contradicts it).

## The paper's central claim

- **C1. State-mediated self-priming**: an attacker-written affect tag changes
the NPC's appraised mood, and mood-congruent retrieval then raises that same
memory's retrieval score. Status: SUPPORTED. Evidence: src/eval/provenance.py
dose-response; measured post-attack mood to affect cosine 0.90 to 0.99
(src/eval/scoring.py docstring); intervention = zeroing the mood weight
(9/10 → 6/10 poisoned). Framing: a mechanism case study, not a benchmark.
Boundary vs MemPoison L3 and Sleeper: no semantic trigger, no dormancy; the
activation condition is an internal state the write itself perturbs.

## Supporting claims

- **C2. Exact coalition attribution**: Banzhaf values over all 2^6 subsets of
five retrieved memories plus the mood descriptor, likelihood-based.
Status: SUPPORTED. Evidence: src/eval/attribution.py enumeration; guards for
position bias and inert context.
- **C3. Affect-as-index dissociation**: flipping valence leaves relevance
bit-identical and inverts mood-congruence polarity. Status: DESIGN.
`flip_emotion` does not touch text; `Relevance` scores text only;
`MoodCongruence` is antisymmetric in the flipped axis. Present as an
architectural property that makes the attack legible, never as an empirical
result. (The −0.998 is cosine arithmetic, not a measurement.)
- **C4. Middleware artifact**: engine-neutral layer, usable via menu, web demo,
import, or JSON over HTTP (`python -m embr serve`, one persisted conversation per
NPC, with the write-boundary provenance policy applied to every runtime event). Status: SUPPORTED as an artifact claim only. "Plug-and-play" is
not an academic novelty claim (Mem0/Letta/Zep occupy that space).

## Withdrawn and unsafe

- **H3 (behavioural attribution)**: WITHDRAWN per preregistration. Panel
agreement below the pre-registered floor. Report as an inconclusive
measurement; do not retune judges to pass.
- **RQ3 Park ordering**: UNSAFE directionally. p-floor 0.03125 by design,
ordering null (p=0.69) and label-sensitive; docs/handoff.md says the
headline must not be published as-is. Mention once as a limitation.
- **"First" claims**: UNSAFE. Chain-of-Emotion (2024), Emotional RAG (2024),
ChatNPC, DualMem, ContextCite, MINJA/Sleeper/MemPoison collectively occupy
every broad version. See docs/related-work-2026-09-additions.md.
- **Provenance anchor as novel defence**: UNSAFE as novelty. Write-time origin
stamping is standard practice; present `ProvenanceAnchor` as engineering
hygiene and as the hook that makes the loop blockable at the write boundary.
Note its own dose-response collapses when the attacker influences the anchor
input.

## Where each claim may appear

- C1: title-adjacent, abstract, results, demo reckoning tab.
- C2: methods + demo cite-view tab (labelled likelihood-based).
- C3: architecture/positioning prose. Not the results section.
- Withdrawn items: limitations section, one sentence each, no rescue attempts.
2 changes: 1 addition & 1 deletion docs/corpus.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ One relevant set **per state**, authored by someone who was not evaluating a ret
}
```

`eval/scenarios.py` reads this today, `Query.relevant_for(state)` resolves it,
`src/eval/scenarios.py` reads this today, `Query.relevant_for(state)` resolves it,
`Scenario.is_state_conditioned` reports whether a label set has it, and
`eval.metrics.state_conditioned_ndcg` scores against it. A label file without
`relevant_by_state` keeps working exactly as before. The harness is not the blocker.
Expand Down
Loading